diff --git a/powershell/Maester.psd1 b/powershell/Maester.psd1
index 76429e2f..317fdaa0 100644
--- a/powershell/Maester.psd1
+++ b/powershell/Maester.psd1
@@ -111,6 +111,9 @@ FunctionsToExport = 'Add-MtTestResultDetail', 'Clear-MtGraphCache', 'Connect-Mae
'Test-MtCisaContactSharing', 'Test-MtCisaCalendarSharing',
'Test-MtCisaExternalSenderWarning', 'Test-MtCisaAntiSpamAllowList',
'Test-MtCisaAntiSpamSafeList', 'Test-MtCisaMailboxAuditing',
+ 'Test-MtCisaSpfRestriction', 'Test-MtCisaSpfDirective', 'Test-MtCisaDkim',
+ 'Test-MtCisaDmarcRecordExist', 'Test-MtCisaDmarcRecordReject',
+ 'Test-MtCisaDmarcAggregateCisa', 'Test-MtCisaDmarcReport',
'Test-MtConditionalAccessWhatIf',
'Test-MtConnection',
'Test-MtEidscaAF01',
@@ -129,7 +132,10 @@ FunctionsToExport = 'Add-MtTestResultDetail', 'Clear-MtGraphCache', 'Connect-Mae
'Test-MtEidscaPR02', 'Test-MtEidscaPR03', 'Test-MtEidscaPR05',
'Test-MtEidscaPR06', 'Test-MtEidscaST08', 'Test-MtEidscaST09',
'Test-MtPimAlertsExists', 'Test-MtPrivPermanentDirectoryRole',
- 'Update-MaesterTests', 'Compare-MtTestResult'
+ 'Update-MaesterTests', 'Compare-MtTestResult', 'Get-MailAuthenticationRecord',
+ 'ConvertFrom-MailAuthenticationRecordSpf', 'ConvertFrom-MailAuthenticationRecordMx',
+ 'ConvertFrom-MailAuthenticationRecordDmarc', 'ConvertFrom-MailAuthenticationRecordDkim',
+ 'Resolve-SpfRecord', 'Clear-MtDnsCache'
# Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export.
CmdletsToExport = @()
diff --git a/powershell/Maester.psm1 b/powershell/Maester.psm1
index f122c89d..b79aad92 100644
--- a/powershell/Maester.psm1
+++ b/powershell/Maester.psm1
@@ -18,6 +18,7 @@ $__MtSession = @{
GraphBaseUri = $null
TestResultDetail = @{}
Connections = @()
+ DnsCache = @()
}
New-Variable -Name __MtSession -Value $__MtSession -Scope Script -Force
diff --git a/powershell/internal/Clear-ModuleVariable.ps1 b/powershell/internal/Clear-ModuleVariable.ps1
index 0f9ed631..06fefdac 100644
--- a/powershell/internal/Clear-ModuleVariable.ps1
+++ b/powershell/internal/Clear-ModuleVariable.ps1
@@ -16,5 +16,6 @@ Function Clear-ModuleVariable {
Clear-MtGraphCache
$__MtSession.GraphBaseUri = $null
$__MtSession.TestResultDetail = @{}
+ Clear-MtDnsCache
# $__MtSession.Connections = @() # Do not clear connections as they are used to track the connection state. This module variable should only be set by Connect-Maester and Disconnect-Maester.
}
diff --git a/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordDkim.ps1 b/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordDkim.ps1
new file mode 100644
index 00000000..a46bacad
--- /dev/null
+++ b/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordDkim.ps1
@@ -0,0 +1,103 @@
+<#
+.SYNOPSIS
+ Returns structured RFC compliant object from DKIM record
+
+.DESCRIPTION
+
+ Adapted from:
+ - https://cloudbrothers.info/en/powershell-tip-resolve-spf/
+ - https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Modules/Providers/ExportEXOProvider.psm1
+ - https://xkln.net/blog/getting-mx-spf-dmarc-dkim-and-smtp-banners-with-powershell/
+ - DKIM https://datatracker.ietf.org/doc/html/rfc6376
+record : v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCPkb8bu8RGWeJGk3hJrouZXIdZ+HTp/azRp8IUOHp5wKvPUAi/54PwuLscUjRk4Rh3hjIkMpKRfJJXPxWbrT7eMLric
+ 7f/S0h+qF4aqIiQqHFCDAYfMnN6V3Wbke2U5EGm0H/cAUYkaf2AtuHJ/rdY/EXaldAm00PgT9QQMez66QIDAQAB;
+keyType : rsa
+hash : {sha1, sha256}
+notes :
+publicKey : MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCPkb8bu8RGWeJGk3hJrouZXIdZ+HTp/azRp8IUOHp5wKvPUAi/54PwuLscUjRk4Rh3hjIkMpKRfJJXPxWbrT7eMLric7f/S0h+qF4aqIiQqHF
+ CDAYfMnN6V3Wbke2U5EGm0H/cAUYkaf2AtuHJ/rdY/EXaldAm00PgT9QQMez66QIDAQAB
+validBase64 : True
+services : {*}
+flags :
+warnings :
+
+.EXAMPLE
+ ConvertFrom-MailAuthenticationRecordDkim -DomainName "microsoft.com"
+
+ Returns [DKIMRecord] or "Failure to obtain record"
+#>
+
+Function ConvertFrom-MailAuthenticationRecordDkim {
+ [OutputType([DKIMRecord],[System.String])]
+ [cmdletbinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$DomainName,
+
+ [ipaddress]$DnsServerIpAddress = "1.1.1.1",
+
+ [string]$DkimSelector = "selector1",
+
+ [switch]$QuickTimeout,
+
+ [switch]$NoHostsFile
+ )
+
+ begin {
+ #TODO, add additional regexs for additional options, pop selector on call
+ #[DKIMRecord]::new("v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCPkb8bu8RGWeJGk3hJrouZXIdZ+HTp/azRp8IUOHp5wKvPUAi/54PwuLscUjRk4Rh3hjIkMpKRfJJXPxWbrT7eMLric7f/S0h+qF4aqIiQqHFCDAYfMnN6V3Wbke2U5EGm0H/cAUYkaf2AtuHJ/rdY/EXaldAm00PgT9QQMez66QIDAQAB;")
+ class DKIMRecord {
+ [string]$record
+ [string]$keyType = "rsa" #k
+ [string[]]$hash = @("sha1","sha256") #h
+ [string]$notes #n
+ [string]$publicKey #p
+ [bool]$validBase64
+ [string[]]$services = "*" #s (*,email)
+ [string[]]$flags #t (y,s)
+ [string[]]$warnings
+
+ hidden $option = [Text.RegularExpressions.RegexOptions]::IgnoreCase
+ hidden $matchRecord = "^v\s*=\s*(?'v'DKIM1)\s*;\s*"
+ hidden $matchKeyType = "k\s*=\s*(?'k'[^;]+)\s*;\s*"
+ hidden $matchPublicKey = "p\s*=\s*(?'p'[^;]+)\s*;\s*"
+
+ DKIMRecord([string]$record){
+ $this.record = $record
+ $match = $record -match $this.matchRecord
+ if(-not $match){
+ $this.warnings = "v: Record does not match version format"
+ break
+ }
+ $p = [regex]::Match($record,$this.matchPublicKey,$this.option)
+ $this.publicKey = ($p.Groups|Where-Object{$_.Name -eq "p"}).Value
+ $bytes = [System.Convert]::FromBase64String(($p.Groups|Where-Object{$_.Name -eq "p"}).Value)
+ $this.validBase64 = $null -ne $bytes
+ }
+ }
+ }
+
+ process {
+ $dkimPrefix = "$DkimSelector._domainkey."
+ $matchRecord = "^v\s*=\s*(?'v'DKIM1)\s*;\s*"
+
+ $dkimSplat = @{
+ Name = "$dkimPrefix$DomainName"
+ Type = "TXT"
+ Server = $DnsServerIpAddress
+ NoHostsFile = $NoHostsFile
+ QuickTimeout = $QuickTimeout
+ ErrorAction = "Stop"
+ }
+ try{
+ $dkimRecord = [DKIMRecord]::new((Resolve-DnsName @dkimSplat | `
+ Where-Object {$_.Type -eq "TXT"} | `
+ Where-Object {$_.Strings -match $matchRecord}).Strings)
+ }catch{
+ Write-Error $Error[0]
+ return "Failure to obtain record"
+ }
+
+ return $dkimRecord
+ }
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordDmarc.ps1 b/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordDmarc.ps1
new file mode 100644
index 00000000..cf9fdbb9
--- /dev/null
+++ b/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordDmarc.ps1
@@ -0,0 +1,234 @@
+<#
+.SYNOPSIS
+ Returns structured RFC compliant object for a DMARC record
+
+.DESCRIPTION
+
+ Adapted from:
+ - https://cloudbrothers.info/en/powershell-tip-resolve-spf/
+ - https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Modules/Providers/ExportEXOProvider.psm1
+ - https://xkln.net/blog/getting-mx-spf-dmarc-dkim-and-smtp-banners-with-powershell/
+ - DMARC https://datatracker.ietf.org/doc/html/rfc7489
+
+record : v=DMARC1; p=reject; pct=100; rua=mailto:itex-rua@microsoft.com; ruf=mailto:itex-ruf@microsoft.com; fo=1
+valid : True
+policy : reject
+policySubdomain :
+percentage : 100
+reportAggregate : {DMARCRecordUri}
+reportForensic : {DMARCRecordUri}
+reportFailure : {1}
+reportFailureFormats : {afrf}
+reportFrequency : 86400
+alignmentDkim : r
+alignmentSpf : r
+version : DMARC1
+warnings : {sp: No subdomain policy set, adkim: No DKIM alignment set, defaults to relaxed, aspf: No SPF alignment set, defaults to relaxed, ri: No
+ report interval set, defaults to 86400 seconds…}
+
+.EXAMPLE
+ ConvertFrom-MailAuthenticationRecordDmarc -DomainName "microsoft.com"
+
+ Returns [DMARCRecord] or "Failure to obtain record"
+#>
+
+Function ConvertFrom-MailAuthenticationRecordDmarc {
+ [OutputType([DMARCRecord],[System.String])]
+ [cmdletbinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$DomainName,
+
+ [ipaddress]$DnsServerIpAddress = "1.1.1.1",
+
+ [switch]$QuickTimeout,
+
+ [switch]$NoHostsFile
+ )
+
+ begin {
+ #[DMARCRecordUri]::new("mailto:itex-ruf@microsoft.com")
+ class DMARCRecordUri {
+ [string]$uri
+ [mailaddress]$mailAddress
+ [string]$reportSizeLimit
+
+ hidden $option = [Text.RegularExpressions.RegexOptions]::IgnoreCase
+ hidden $matchUri = "(?'uri'mailto:(?'address'[^,!]*)(?:!(?'size'\d+(?:k|m|g|t)))?)(?:,|$)"
+
+ DMARCRecordUri([string]$uri){
+ $this.uri = $uri
+ $match = [regex]::Match($uri,$this.matchUri,$this.option)
+ $this.mailAddress = ($match.Groups|Where-Object{$_.Name -eq "address"}).Value
+ $this.reportSizeLimit = ($match.Groups|Where-Object{$_.Name -eq "size"}).Value
+ }
+ }
+
+ #[DMARCRecord]::new("v=DMARC1; p=reject; pct=100; rua=mailto:itex-rua@microsoft.com; ruf=mailto:itex-ruf@microsoft.com; fo=1")
+ class DMARCRecord {
+ [string]$record
+ [bool]$valid
+ [ValidateSet("none","quarantine","reject")]
+ [string]$policy #p
+ [string]$policySubdomain #sp
+ [ValidateRange(0,100)]
+ [int]$percentage = 100 #pct
+ [DMARCRecordUri[]]$reportAggregate #rua
+ [DMARCRecordUri[]]$reportForensic #ruf
+ [ValidateSet("0","1","d","s")]
+ [string[]]$reportFailure #fo
+ [string[]]$reportFailureFormats = "afrf" #rf
+ [int]$reportFrequency = 86400 #ri
+ [ValidateSet("r","s")]
+ [string]$alignmentDkim = "r" #adkim
+ [ValidateSet("r","s")]
+ [string]$alignmentSpf = "r" #aspf
+ [string]$version = "DMARC1"
+ [string[]]$warnings
+
+ hidden $option = [Text.RegularExpressions.RegexOptions]::IgnoreCase
+ hidden $matchInit = "^v\s*=\s*(?'v'DMARC1)\s*;\s*p\s*=\s*(?'p'none|quarantine|reject)(?:$|\s*;\s*)"
+ hidden $matchSp = "sp\s*=\s*(?'sp'none|quarantine|reject)(?:$|\s*;\s*)"
+ hidden $matchRua = "rua\s*=\s*(?'rua'[^;]+)(?:$|\s*;\s*)"
+ hidden $matchRuf = "ruf\s*=\s*(?'ruf'[^;]+)(?:$|\s*;\s*)"
+ hidden $matchUri = "(?'uri'mailto:(?'address'[^,!]*)(?:!(?'size'\d+(?:k|m|g|t)))?)(?:,|$)"
+ hidden $matchAdkim = "adkim\s*=\s*(?'adkim'r|s)(?:$|\s*;\s*)"
+ hidden $matchAspf = "aspf\s*=\s*(?'aspf'r|s)(?:$|\s*;\s*)"
+ hidden $matchRi = "ri\s*=\s*(?'ri'\d+)(?:$|\s*;\s*)"
+ hidden $matchFo = "fo\s*=\s*(?'fo'.{1})(?:$|\s*;\s*)"
+ hidden $matchOptions = "(?'opt'[^:\s])(?:\s*:|\s*$)"
+ hidden $matchRf = "rf\s*=\s*(?'rf'[^;]+)(?:$|\s*;\s*)"
+ hidden $matchFormat = "(?'format'[^:\s]*)(?:\s*:|\s*$)"
+ hidden $matchPct = "pct\s*=\s*(?'pct'\d{1,3})(?:$|\s*;\s*)"
+
+ DMARCRecord([string]$record){
+ $this.record = $record
+ $init = $record -match $this.matchInit
+ $this.valid = $init
+ if(-not $init){
+ $this.warnings += "v/p: Record version (v) and policy (p) configuration is not proper"
+ exit
+ }
+ $this.version = $Matches["v"]
+ $this.policy = $Matches["p"]
+
+ $sp = $record -match $this.matchSp
+ if(-not $sp){
+ $this.warnings += "sp: No subdomain policy set"
+ }else{
+ $this.policySubdomain = $Matches["sp"]
+ }
+
+ $rua = $record -match $this.matchRua
+ if(-not $rua){
+ $this.warnings += "rua: No aggregate report URI set"
+ }else{
+ $uris = [regex]::Matches($Matches["rua"],$this.matchUri,$this.option)
+ foreach($uri in ($uris.Groups|Where-Object{$_.Name -eq "uri"})){
+ $this.reportAggregate += [DMARCRecordUri]::new("$uri")
+ }
+ if(($uris.Groups|Where-Object{$_.Name -eq "uri"}|Measure-Object).Count -gt 2){
+ $this.warnings += "ruf: More than 2 URIs set and may be ignored"
+ }
+ }
+
+ $ruf = $record -match $this.matchRuf
+ if(-not $ruf){
+ $this.warnings += "ruf: No forensic report URI set"
+ }else{
+ $uris = [regex]::Matches($Matches["ruf"],$this.matchUri,$this.option)
+ foreach($uri in ($uris.Groups|Where-Object{$_.Name -eq "uri"})){
+ $this.reportForensic += [DMARCRecordUri]::new("$uri")
+ }
+ if(($uris.Groups|Where-Object{$_.Name -eq "uri"}|Measure-Object).Count -gt 2){
+ $this.warnings += "ruf: More than 2 URIs set and may be ignored"
+ }
+ }
+
+ $adkim = $record -match $this.matchAdkim
+ if(-not $adkim){
+ $this.warnings += "adkim: No DKIM alignment set, defaults to relaxed"
+ }else{
+ $this.alignmentDkim = $Matches["adkim"]
+ }
+
+ $aspf = $record -match $this.matchAspf
+ if(-not $aspf){
+ $this.warnings += "aspf: No SPF alignment set, defaults to relaxed"
+ }else{
+ $this.alignmentSpf = $Matches["aspf"]
+ }
+
+ $ri = $record -match $this.matchRi
+ if(-not $ri){
+ $this.warnings += "ri: No report interval set, defaults to 86400 seconds"
+ }else{
+ $this.ri = $Matches["ri"]
+ }
+
+ $fo = $record -match $this.matchFo
+ if(-not $fo){
+ $this.reportFailure = "0"
+ $this.warnings += "fo: No failure reporting option specified, default (0) report when all mechanisms fail to pass"
+ }elseif($fo -and -not $ruf){
+ $this.warnings += "fo: Failure reporting option specified, but no ruf URI set"
+ }else{
+ $options = [regex]::Matches($Matches["fo"],$this.matchOptions,$this.option)
+ foreach($option in ($options.Groups|Where-Object{$_.Name -eq "opt"})){
+ $this.reportFailure += $option
+ }
+ }
+
+ $rf = $record -match $this.matchRf
+ if(-not $rf){
+ $this.warnings += "rf: No failure report format specified, defaults to afrf"
+ }else{
+ $formats = [regex]::Matches($Matches["rf"],$this.matchFormat,$this.option)
+ foreach($format in $formats.Groups|Where-Object{$_.Name -eq "format"}){
+ switch ($format.Value) {
+ "afrf" {
+ $this.reportFailureFormats += $format.Value
+ }
+ "" {}
+ Default {
+ $this.reportFailureFormats += $format.Value
+ $this.warnings += "rf: Unkown failure report format ($($format.Value)) specified"
+ }
+ }
+ }
+ }
+
+ $pct = $record -match $this.matchPct
+ if(-not $pct){
+ $this.warnings += "pct: No percentage of messages specified to apply policy to, defaults to 100"
+ }else{
+ $this.percentage = $Matches["pct"]
+ }
+ }
+ }
+ }
+
+ process {
+ $dmarcPrefix = "_dmarc."
+ $matchRecord = "^v\s*=\s*(?'v'DMARC1)\s*;\s*p\s*=\s*(?'p'none|quarantine|reject)(?:$|\s*;\s*)"
+
+ $dmarcSplat = @{
+ Name = "$dmarcPrefix$DomainName"
+ Type = "TXT"
+ Server = $DnsServerIpAddress
+ NoHostsFile = $NoHostsFile
+ QuickTimeout = $QuickTimeout
+ ErrorAction = "Stop"
+ }
+ try{
+ $dmarcRecord = [DMARCRecord]::new((Resolve-DnsName @dmarcSplat | `
+ Where-Object {$_.Type -eq "TXT"} | `
+ Where-Object {$_.Strings -match $matchRecord}).Strings)
+ }catch{
+ Write-Error $Error[0]
+ return "Failure to obtain record"
+ }
+
+ return $dmarcRecord
+ }
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordMx.ps1 b/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordMx.ps1
new file mode 100644
index 00000000..34d9f1a2
--- /dev/null
+++ b/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordMx.ps1
@@ -0,0 +1,49 @@
+<#
+.SYNOPSIS
+ A simple wrapper for Resolve-DnsName
+
+.DESCRIPTION
+
+Name Type TTL Section NameExchange Preference
+---- ---- --- ------- ------------ ----------
+microsoft.com MX 1731 Answer microsoft-com.mail.protection.outlook.com 10
+
+.EXAMPLE
+ ConvertFrom-MailAuthenticationRecordMx -DomainName "microsoft.com"
+
+ Returns MX records or "Failure to obtain record"
+#>
+
+Function ConvertFrom-MailAuthenticationRecordMx {
+ [OutputType([Microsoft.DnsClient.Commands.DnsRecord_MX],[System.String])]
+ [cmdletbinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$DomainName,
+
+ [ipaddress]$DnsServerIpAddress = "1.1.1.1",
+
+ [switch]$QuickTimeout,
+
+ [switch]$NoHostsFile
+ )
+
+ process {
+ $mxSplat = @{
+ Name = $DomainName
+ Type = "MX"
+ Server = $DnsServerIpAddress
+ NoHostsFile = $NoHostsFile
+ QuickTimeout = $QuickTimeout
+ ErrorAction = "Stop"
+ }
+ try{
+ $mxRecords = Resolve-DnsName @mxSplat | Where-Object {$_.Type -eq "MX"}
+ }catch{
+ Write-Error $Error[0]
+ return "Failure to obtain record"
+ }
+
+ return $mxRecords
+ }
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordSpf.ps1 b/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordSpf.ps1
new file mode 100644
index 00000000..9d63ffdf
--- /dev/null
+++ b/powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordSpf.ps1
@@ -0,0 +1,136 @@
+<#
+.SYNOPSIS
+ Returns a structured RFC compliant object for the supplied SPF record
+
+.DESCRIPTION
+
+ Adapted from:
+ - https://cloudbrothers.info/en/powershell-tip-resolve-spf/
+ - https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Modules/Providers/ExportEXOProvider.psm1
+ - https://xkln.net/blog/getting-mx-spf-dmarc-dkim-and-smtp-banners-with-powershell/
+ - SPF https://datatracker.ietf.org/doc/html/rfc7208
+
+record : v=spf1 include:_spf-a.microsoft.com include:_spf-b.microsoft.com include:_spf-c.microsoft.com include:_spf-ssg-a.msft.net include:spf-a.hotmail.com
+ include:_spf1-meo.microsoft.com -all
+terms : {SPFRecordTerm, SPFRecordTerm, SPFRecordTerm, SPFRecordTerm…}
+warnings :
+
+.EXAMPLE
+ ConvertFrom-MailAuthenticationRecordSpf -DomainName "microsoft.com"
+
+ Returns [SPFRecord] object or "Failure to obtain record"
+#>
+
+Function ConvertFrom-MailAuthenticationRecordSpf {
+ [OutputType([SPFRecord],[System.String])]
+ [cmdletbinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$DomainName,
+
+ [ipaddress]$DnsServerIpAddress = "1.1.1.1",
+
+ [switch]$QuickTimeout,
+
+ [switch]$NoHostsFile
+ )
+
+ begin {
+ #todo, check that all is always at end,
+ ###check that ptr isn't used,
+ ###check for repeat modifiers,
+ ###check for all and redirect,
+ ###check for unrecognized modifiers,
+ ###recommend exp if not found #https://datatracker.ietf.org/doc/html/rfc7208#section-6.2,
+ ###check for macros #https://datatracker.ietf.org/doc/html/rfc7208#section-7,
+ ###check for 10* include, a, mx, ptr, exists
+ #[SPFRecordTerm]::new("include:_spf-a.microsoft.com")
+ class SPFRecordTerm {
+ [string]$term #term
+ [string]$directive #directive
+ [ValidateSet("+","-","~","?","")]
+ [string]$qualifier #qual
+ [ValidateSet("all","include","a","mx","ptr","ip4","ip6","exists","")]
+ [string]$mechanism #mech
+ [string]$mechanismTarget #mechTarget
+ [string]$mechanismTargetCidr #cidr
+ [ValidateSet("redirect","exp","")]
+ [string]$modifier #mod
+ [string]$modifierTarget #modTarget
+
+ hidden $option = [Text.RegularExpressions.RegexOptions]::IgnoreCase
+ hidden $matchTerms = "\s*(?'term'(?'directive'(?'qual'\+|-|~|\?)?(?'mech'all|include|a|mx|ptr|ip4|ip6|exists)(?::?(?'mechTarget'[^\s]+?(?'cidr'\/[^\s]+)?))?)(?:\s|$)|(?'mod'redirect|exp)(?:=(?'modTarget'[^\s]+))(?:\s|$))"
+
+ SPFRecordTerm([string]$term){
+ $this.term = $term
+ $match = [regex]::Match($term,$this.matchTerms,$this.option)
+ $this.directive = ($match.Groups|Where-Object{$_.Name -eq "directive"}).Value
+ $qVal = ($match.Groups|Where-Object{$_.Name -eq "qual"}).Value
+ if($qVal -eq ""){
+ $q = "?"
+ }else{
+ $q = $qVal
+ }
+ $this.qualifier = $q
+ $this.mechanism = ($match.Groups|Where-Object{$_.Name -eq "mech"}).Value
+ $this.mechanismTarget = ($match.Groups|Where-Object{$_.Name -eq "mechTarget"}).Value
+ $this.mechanismTargetCidr = ($match.Groups|Where-Object{$_.Name -eq "cidr"}).Value
+ $this.modifier = ($match.Groups|Where-Object{$_.Name -eq "mod"}).Value
+ $this.modifierTarget = ($match.Groups|Where-Object{$_.Name -eq "modTarget"}).Value
+ }
+ }
+
+ #[spfrecord]::new("v=spf1 include:_spf-a.microsoft.com include:_spf-b.microsoft.com include:_spf-c.microsoft.com include:_spf-ssg-a.msft.net include:spf-a.hotmail.com include:_spf1-meo.microsoft.com -all")
+ class SPFRecord {
+ [string]$record
+ [SPFRecordTerm[]]$terms
+ [string]$warnings
+
+
+ hidden $option = [Text.RegularExpressions.RegexOptions]::IgnoreCase# -bor [Text.RegularExpressions.RegexOptions]::Singleline
+ hidden $matchRecord = "^v=spf1 .*$"
+ #https://datatracker.ietf.org/doc/html/rfc7208#section-12
+ hidden $matchTerms = "\s*(?'term'(?'directive'(?'qual'\+|-|~|\?)?(?'mech'all|include|a|mx|ptr|ip4|ip6|exists)(?::?(?'mechTarget'[^\s]+?(?'cidr'\/[^\s]+)?))?)(?:\s|$)|(?'mod'redirect|exp)(?:=(?'modTarget'[^\s]+))(?:\s|$))"
+
+ SPFRecord([string]$inputRecord){
+ $this.record = $inputRecord
+ $match = [regex]::Matches($inputRecord,$this.matchRecord,$this.option)
+ if(-not $match){
+ $this.warnings += "v: Record does not match spf1 version format"
+ break
+ }
+ if(($match|Measure-Object).Count -gt 1){
+ $this.warnings += "v: Multiple records match spf1 version format"
+ break
+ }
+ $recordTerms = [regex]::Matches($inputRecord,$this.matchTerms,$this.option)
+ foreach($term in ($recordTerms.Groups|Where-Object{$_.Name -eq "term"})){
+ $this.terms += [SPFRecordTerm]::new($term.Value)
+ }
+ }
+ }
+ }
+
+ process {
+ $matchRecord = "^v=spf1 .*$"
+
+ $spfSplat = @{
+ Name = $DomainName
+ Type = "TXT"
+ Server = $DnsServerIpAddress
+ NoHostsFile = $NoHostsFile
+ QuickTimeout = $QuickTimeout
+ ErrorAction = "Stop"
+ }
+ try{
+ $spfRecord = [SPFRecord]::new((Resolve-DnsName @spfSplat | `
+ Where-Object {$_.Type -eq "TXT"} | `
+ Where-Object {$_.Strings -imatch $matchRecord}).Strings)
+ }catch{
+ Write-Error $Error[0]
+ return "Failure to obtain record"
+ }
+
+ return $spfRecord
+ }
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Get-MailAuthenticationRecord.ps1 b/powershell/public/CISA/exchange/Get-MailAuthenticationRecord.ps1
new file mode 100644
index 00000000..a00017fb
--- /dev/null
+++ b/powershell/public/CISA/exchange/Get-MailAuthenticationRecord.ps1
@@ -0,0 +1,136 @@
+<#
+.SYNOPSIS
+ Obtains and converts the mail authentication records of a domain
+
+.DESCRIPTION
+
+ Adapted from:
+ - https://cloudbrothers.info/en/powershell-tip-resolve-spf/
+ - https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Modules/Providers/ExportEXOProvider.psm1
+ - https://xkln.net/blog/getting-mx-spf-dmarc-dkim-and-smtp-banners-with-powershell/
+ - SPF https://datatracker.ietf.org/doc/html/rfc7208
+ - DMARC https://datatracker.ietf.org/doc/html/rfc7489
+ - DKIM https://datatracker.ietf.org/doc/html/rfc6376
+
+.EXAMPLE
+ Get-MailAuthenticationRecord -DomainName "microsoft.com"
+
+ Returns an object containing the structured mail authentication objects
+#>
+
+Function Get-MailAuthenticationRecord {
+ [OutputType([pscustomobject])]
+ [cmdletbinding()]
+ param(
+ [Parameter(Mandatory)]
+ [string]$DomainName,
+
+ [ipaddress]$DnsServerIpAddress = "1.1.1.1",
+
+ [string]$DkimSelector = "selector1",
+
+ [ValidateSet("All","DKIM","DMARC","MX","SPF")]
+ [string[]]$Records = "All",
+
+ [switch]$QuickTimeout,
+
+ [switch]$NoHostsFile
+ )
+
+ begin {
+ if($Records -contains "All"){
+ $all = $dkim = $dmarc = $mx = $spf = $true
+ }else{
+ foreach($record in $Records){
+ Set-Variable -Name $record -Value $true
+ }
+ }
+ }
+
+ process {
+ $recordSet = [pscustomobject]@{
+ domain = $DomainName
+ mxRecords = $null
+ spfRecord = $null
+ spfLookups = $null
+ dmarcRecord = $null
+ dkimRecord = $null
+ }
+
+ if(($__MtSession.DnsCache|Where-Object{$_.domain -eq $DomainName}|Measure-Object).Count -eq 0){
+ $__MtSession.DnsCache += $recordSet
+ $mtDnsCache = $recordSet
+ }else{
+ $mtDnsCache = $__MtSession.DnsCache|Where-Object{$_.domain -eq $DomainName}
+ }
+
+ $splat = @{
+ DomainName = $DomainName
+ DnsServerIpAddress = $DnsServerIpAddress
+ QuickTimeout = $QuickTimeout
+ NoHostsFile = $NoHostsFile
+ }
+
+ if($mx -or $all){
+ if($null -ne $mtDnsCache.mxRecords){
+ Write-Verbose "MX records exist in cache - Use Clear-MtDnsCache to reset"
+ $recordSet.mxRecords = $mtDnsCache.mxRecords
+ }else{
+ $recordSet.mxRecords = ConvertFrom-MailAuthenticationRecordMx @splat
+ ($__MtSession.DnsCache|Where-Object{$_.domain -eq $DomainName}).mxRecords = $recordSet.mxRecords
+ }
+ }
+
+ if($spf -or $all){
+ if($null -ne $mtDnsCache.spfRecord){
+ Write-Verbose "SPF record exist in cache - Use Clear-MtDnsCache to reset"
+ $recordSet.spfRecord = $mtDnsCache.spfRecord
+ Write-Verbose "SPF record exist in cache - Skipping SPF Lookups"
+ $recordSet.spfLookups = $mtDnsCache.spfLookups
+ }else{
+ $recordSet.spfRecord = ConvertFrom-MailAuthenticationRecordSpf @splat
+ if($recordSet.spfRecord.terms.modifier -contains "redirect"){
+ Write-Verbose "SPF redirect modifier found, recursing"
+ $redirect = ($recordSet.spfRecord.terms|Where-Object {`
+ $_.modifier -eq "redirect"
+ }).modifierTarget
+ Get-MailAuthenticationRecord -DomainName $redirect
+ }
+ if($recordSet.spfRecord -ne "Failure to obtain record"){
+ Write-Verbose "SPF record resolved, checking lookups"
+ if($null -ne $mtDnsCache.spfLookups){
+ Write-Verbose "SPF Lookups records exist in cache - Use Clear-MtDnsCache to reset"
+ $recordSet.spfLookups = $mtDnsCache.spfLookups
+ }else{
+ Write-Verbose "SPF Lookups records not in cache, querying"
+ $recordSet.spfLookups = Resolve-SPFRecord -Name $DomainName -Server $DnsServerIpAddress
+ ($__MtSession.DnsCache|Where-Object{$_.domain -eq $DomainName}).spfLookups = $recordSet.spfLookups
+ }
+ }
+ ($__MtSession.DnsCache|Where-Object{$_.domain -eq $DomainName}).spfRecord = $recordSet.spfRecord
+ }
+ }
+
+ if($dkim -or $all){
+ if($null -ne $mtDnsCache.dkimRecord){
+ Write-Verbose "DKIM record exist in cache - Use Clear-MtDnsCache to reset"
+ $recordSet.dkimRecord = $mtDnsCache.dkimRecord
+ }else{
+ $recordSet.dkimRecord = ConvertFrom-MailAuthenticationRecordDkim @splat -DkimSelector $DkimSelector
+ ($__MtSession.DnsCache|Where-Object{$_.domain -eq $DomainName}).dkimRecord = $recordSet.dkimRecord
+ }
+ }
+
+ if($dmarc -or $all){
+ if($null -ne $mtDnsCache.dmarcRecord){
+ Write-Verbose "DMARC record exist in cache - Use Clear-MtDnsCache to reset"
+ $recordSet.dmarcRecord = $mtDnsCache.dmarcRecord
+ }else{
+ $recordSet.dmarcRecord = ConvertFrom-MailAuthenticationRecordDmarc @splat
+ ($__MtSession.DnsCache|Where-Object{$_.domain -eq $DomainName}).dmarcRecord = $recordSet.dmarcRecord
+ }
+ }
+
+ return $recordSet
+ }
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Resolve-SPFRecord.ps1 b/powershell/public/CISA/exchange/Resolve-SPFRecord.ps1
new file mode 100644
index 00000000..082e400c
--- /dev/null
+++ b/powershell/public/CISA/exchange/Resolve-SPFRecord.ps1
@@ -0,0 +1,182 @@
+<#
+.SYNOPSIS
+ Returns a list of all IP addresses from an SPF record
+
+.DESCRIPTION
+
+ https://cloudbrothers.info/en/powershell-tip-resolve-spf/
+
+.EXAMPLE
+ Resolve-SPFRecord microsoft.com
+
+#>
+
+function Resolve-SPFRecord {
+ [OutputType([spfrecord[]])]
+ [CmdletBinding()]
+ param (
+ # Domain Name
+ [Parameter(Mandatory = $true,
+ ValueFromPipeline = $true,
+ ValueFromPipelineByPropertyName = $true,
+ Position = 1)]
+ [string]$Name,
+
+ # DNS Server to use
+ [Parameter(Mandatory = $false,
+ ValueFromPipelineByPropertyName = $true,
+ Position = 2)]
+ [string]$Server = "1.1.1.1",
+
+ # If called nested provide a referrer to build valid objects
+ [Parameter(Mandatory = $false)]
+ [string]$Referrer
+ )
+
+ begin {
+ class SPFRecord {
+ [string] $SPFSourceDomain
+ [string] $IPAddress
+ [string] $Referrer
+ [string] $Qualifier
+ [bool] $Include
+
+ # Constructor: Creates a new SPFRecord object, with a specified IPAddress
+ SPFRecord ([string] $IPAddress) {
+ $this.IPAddress = $IPAddress
+ }
+
+ # Constructor: Creates a new SPFRecord object, with a specified IPAddress and DNSName
+ SPFRecord ([string] $IPAddress, [String] $DNSName) {
+ $this.IPAddress = $IPAddress
+ $this.SPFSourceDomain = $DNSName
+ }
+
+ # Constructor: Creates a new SPFRecord object, with a specified IPAddress and DNSName and
+ SPFRecord ([string] $IPAddress, [String] $DNSName, [String] $Qualifier) {
+ $this.IPAddress = $IPAddress
+ $this.SPFSourceDomain = $DNSName
+ $this.Qualifier = $Qualifier
+ }
+ }
+ }
+
+ process {
+ # Keep track of number of DNS queries
+ # DNS Lookup Limit = 10
+ # https://tools.ietf.org/html/rfc7208#section-4.6.4
+ # Query DNS Record
+ $DNSRecords = Resolve-DnsName -Server $Server -Name $Name -Type TXT
+ # Check SPF record
+ $SPFRecord = $DNSRecords | Where-Object { $_.Strings -match "^v=spf1" }
+ # Validate SPF record
+ $SPFCount = ($SPFRecord | Measure-Object).Count
+
+ if ( $SPFCount -eq 0) {
+ # If there is no error show an error
+ Write-Error "No SPF record found for `"$Name`""
+ }
+ elseif ( $SPFCount -ge 2 ) {
+ # Multiple DNS Records are not allowed
+ # https://tools.ietf.org/html/rfc7208#section-3.2
+ Write-Error "There is more than one SPF for domain `"$Name`""
+ }
+ else {
+ # Multiple Strings in a Single DNS Record
+ # https://tools.ietf.org/html/rfc7208#section-3.3
+ $SPFString = $SPFRecord.Strings -join ''
+ # Split the directives at the whitespace
+ $SPFDirectives = $SPFString -split " "
+
+ # Check for a redirect
+ if ( $SPFDirectives -match "redirect" ) {
+ $RedirectRecord = $SPFDirectives -match "redirect" -replace "redirect="
+ Write-Verbose "[REDIRECT]`t$RedirectRecord"
+ # Follow the include and resolve the include
+ Resolve-SPFRecord -Name "$RedirectRecord" -Server $Server -Referrer $Name
+ }
+ else {
+
+ # Extract the qualifier
+ $Qualifier = switch ( $SPFDirectives -match "^[+-?~]all$" -replace "all" ) {
+ "+" { "pass" }
+ "-" { "fail" }
+ "~" { "softfail" }
+ "?" { "neutral" }
+ }
+
+ $ReturnValues = foreach ($SPFDirective in $SPFDirectives) {
+ switch -Regex ($SPFDirective) {
+ "%[{%-_]" {
+ Write-Warning "[$_]`tMacros are not supported. For more information, see https://tools.ietf.org/html/rfc7208#section-7"
+ Continue
+ }
+ "^exp:.*$" {
+ Write-Warning "[$_]`tExplanation is not supported. For more information, see https://tools.ietf.org/html/rfc7208#section-6.2"
+ Continue
+ }
+ '^include:.*$' {
+ # Follow the include and resolve the include
+ Resolve-SPFRecord -Name ( $SPFDirective -replace "^include:" ) -Server $Server -Referrer $Name
+ }
+ '^ip[46]:.*$' {
+ Write-Verbose "[IP]`tSPF entry: $SPFDirective"
+ $SPFObject = [SPFRecord]::New( ($SPFDirective -replace "^ip[46]:"), $Name, $Qualifier)
+ if ( $PSBoundParameters.ContainsKey('Referrer') ) {
+ $SPFObject.Referrer = $Referrer
+ $SPFObject.Include = $true
+ }
+ $SPFObject
+ }
+ '^a:.*$' {
+ Write-Verbose "[A]`tSPF entry: $SPFDirective"
+ $DNSRecords = Resolve-DnsName -Server $Server -Name $Name -Type A
+ # Check SPF record
+ foreach ($IPAddress in ($DNSRecords.IPAddress) ) {
+ $SPFObject = [SPFRecord]::New( $IPAddress, ($SPFDirective -replace "^a:"), $Qualifier)
+ if ( $PSBoundParameters.ContainsKey('Referrer') ) {
+ $SPFObject.Referrer = $Referrer
+ $SPFObject.Include = $true
+ }
+ $SPFObject
+ }
+ }
+ '^mx:.*$' {
+ Write-Verbose "[MX]`tSPF entry: $SPFDirective"
+ $DNSRecords = Resolve-DnsName -Server $Server -Name $Name -Type MX
+ foreach ($MXRecords in ($DNSRecords.NameExchange) ) {
+ # Check SPF record
+ $DNSRecords = Resolve-DnsName -Server $Server -Name $MXRecords -Type A
+ foreach ($IPAddress in ($DNSRecords.IPAddress) ) {
+ $SPFObject = [SPFRecord]::New( $IPAddress, ($SPFDirective -replace "^mx:"), $Qualifier)
+ if ( $PSBoundParameters.ContainsKey('Referrer') ) {
+ $SPFObject.Referrer = $Referrer
+ $SPFObject.Include = $true
+ }
+ $SPFObject
+ }
+ }
+ }
+ Default {
+ Write-Warning "[$_]`t Unknown directive"
+ }
+ }
+ }
+
+ $DNSQuerySum = $ReturnValues | Select-Object -Unique SPFSourceDomain | Measure-Object | Select-Object -ExpandProperty Count
+ if ( $DNSQuerySum -gt 6) {
+ Write-Warning "Watch your includes!`nThe maximum number of DNS queries is 10 and you have already $DNSQuerySum.`nCheck https://tools.ietf.org/html/rfc7208#section-4.6.4"
+ }
+ if ( $DNSQuerySum -gt 10) {
+ Write-Error "Too many DNS queries made ($DNSQuerySum).`nMust not exceed 10 DNS queries.`nCheck https://tools.ietf.org/html/rfc7208#section-4.6.4"
+ }
+
+ return $ReturnValues
+ }
+ }
+ }
+
+ end {
+
+ }
+}
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDkim.md b/powershell/public/CISA/exchange/Test-MtCisaDkim.md
new file mode 100644
index 00000000..f3fbb74e
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDkim.md
@@ -0,0 +1,16 @@
+DKIM SHOULD be enabled for all domains.
+
+Rationale: An adversary may modify the `FROM` field of an email such that it appears to be a legitimate email sent by an agency, facilitating phishing attacks. Enabling DKIM is another means for recipients to detect spoofed emails and verify the integrity of email content.
+
+#### Remediation action:
+
+To enable DKIM, follow the instructions listed on [Steps to Create, enable and disable DKIM from Microsoft 365 Defender portal | Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/email-authentication-dkim-configure?view=o365-worldwide#steps-to-create-enable-and-disable-dkim-from-microsoft-365-defender-portal).
+
+#### Related links
+
+* [Defender admin center - Email authentication settings](https://security.microsoft.com/authentication?viewid=DKIM)
+* [CISA 3 Sender Policy Framework - MS.EXO.3.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo31v1)
+* [CISA ScubaGear Rego Reference](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Rego/EXOConfig.rego#L107)
+
+
+%TestResult%
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDkim.ps1 b/powershell/public/CISA/exchange/Test-MtCisaDkim.ps1
new file mode 100644
index 00000000..28d38c65
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDkim.ps1
@@ -0,0 +1,105 @@
+<#
+.SYNOPSIS
+ Checks state of DKIM for all EXO domains
+
+.DESCRIPTION
+
+ DKIM SHOULD be enabled for all domains.
+
+.EXAMPLE
+ Test-MtCisaDkim
+
+ Returns true if DKIM record exists and EXO shows DKIM enabled
+#>
+
+Function Test-MtCisaDkim {
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param(
+ [string]$Selector = "selector1"
+ )
+
+ if(!(Test-MtConnection ExchangeOnline)){
+ Add-MtTestResultDetail -SkippedBecause NotConnectedExchange
+ return $null
+ }
+
+ $signingConfig = Get-DkimSigningConfig
+ $acceptedDomains = Get-AcceptedDomain
+ <# DKIM record without key for parked domains
+ $sendingDomains = $acceptedDomains | Where-Object {`
+ -not $_.SendingFromDomainDisabled
+ }
+ #>
+
+ $dkimRecords = @()
+ foreach($domain in $acceptedDomains){
+ $config = $signingConfig | Where-Object {`
+ $_.domain -eq $domain.domainname
+ }
+ if((Get-Date) -gt $config.RotateOnDate){
+ if($Selector -ne $config.SelectorAfterRotateOnDate){
+ Write-Warning "Using DKIM $($config.SelectorAfterRotateOnDate) based on EXO config"
+ }
+ $Selector = $config.SelectorAfterRotateOnDate
+ }else{
+ if($Selector -ne $config.SelectorBeforeRotateOnDate){
+ Write-Warning "Using DKIM $($config.SelectorBeforeRotateOnDate) based on EXO config"
+ }
+ $selector = $config.SelectorBeforeRotateOnDate
+ }
+
+ $dkimRecord = Get-MailAuthenticationRecord -DomainName $domain.DomainName -DkimSelector $Selector -Records DKIM
+ $dkimRecord | Add-Member -MemberType NoteProperty -Name "pass" -Value "Failed"
+ $dkimRecord | Add-Member -MemberType NoteProperty -Name "reason" -Value ""
+
+ if($dkimRecord.dkimRecord.GetType().Name -eq "DKIMRecord" -and $config.enabled){
+ if(-not $dkimRecord.dkimRecord.validBase64){
+ $dkimRecord.reason = "Malformed public key"
+ }else{
+ $dkimRecord.pass = "Passed"
+ }
+ }elseif($dkimRecord.dkimRecord.GetType().Name -eq "DKIMRecord" -and -not $config.enabled){
+ $dkimRecord.pass = "Skipped"
+ $dkimRecord.reason = "Parked domain"
+ }else{
+ $dkimRecord.reason = $dkimRecord.dkimRecord
+ }
+
+ $dkimRecords += $dkimRecord
+ }
+
+ if("Failed" -in $dkimRecords.pass){
+ $testResult = $false
+ }else{
+ $testResult = $true
+ }
+
+ $portalLink = "https://security.microsoft.com/authentication?viewid=DKIM"
+
+ if($testResult){
+ $testResultMarkdown = "Well done. Your tenant's domains have DKIM configured and valid records exist.`n`n%TestResult%"
+ }else{
+ $testResultMarkdown = "Your tenant's domains do not have DKIM fully deployed. Review [EXO configuration]($portalLink) and DNS records.`n`n%TestResult%"
+ }
+
+ $passResult = "✅ Pass"
+ $failResult = "❌ Fail"
+ $skipResult = "🗄️ Skip"
+ $result = "| Domain | Result | Reason |`n"
+ $result += "| --- | --- | --- |`n"
+ foreach ($item in $dkimRecords | Sort-Object -Property domain) {
+ switch($item.pass){
+ "Passed" {$itemResult = $passResult}
+ "Skipped" {$itemResult = $skipResult}
+ "Failed" {$itemResult = $failResult}
+ }
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) |`n"
+ }
+
+ $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
+
+ Add-MtTestResultDetail -Result $testResultMarkdown
+
+ return $testResult
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDmarcAggregateCisa.md b/powershell/public/CISA/exchange/Test-MtCisaDmarcAggregateCisa.md
new file mode 100644
index 00000000..49a5ded7
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDmarcAggregateCisa.md
@@ -0,0 +1,19 @@
+The DMARC point of contact for aggregate reports SHALL include `reports@dmarc.cyber.dhs.gov`.
+
+Rationale: Email spoofing attempts are not inherently visible to domain owners. DMARC provides a mechanism to receive reports of spoofing attempts. Including reports@dmarc.cyber.dhs.gov as a point of contact for these reports gives CISA insight into spoofing attempts and is required by BOD 18-01 for FCEB departments and agencies.
+
+**Note: Only federal, executive branch, departments and agencies should include this email address in their DMARC record.**
+
+#### Remediation action:
+
+* See MS.EXO.4.1v1 Instructions for an overview of how to publish and check a DMARC record.
+* Ensure the record published includes reports@dmarc.cyber.dhs.gov as one of the emails for the RUA field.
+
+#### Related links
+
+* [Exchange admin center - Accepted domains](https://admin.exchange.microsoft.com/#/accepteddomains)
+* [CISA 4 Domain-Based Message Authentication, Reporting, and Conformance (DMARC) - MS.EXO.4.3v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo43v1)
+* [CISA ScubaGear Rego Reference](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Rego/EXOConfig.rego#L207)
+
+
+%TestResult%
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDmarcAggregateCisa.ps1 b/powershell/public/CISA/exchange/Test-MtCisaDmarcAggregateCisa.ps1
new file mode 100644
index 00000000..95521d82
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDmarcAggregateCisa.ps1
@@ -0,0 +1,117 @@
+<#
+.SYNOPSIS
+ Checks state of DMARC records for all exo domains
+
+.DESCRIPTION
+
+ The DMARC point of contact for aggregate reports SHALL include reports@dmarc.cyber.dhs.gov.
+
+.EXAMPLE
+ Test-MtCisaDmarcAggregateCisa
+
+ Returns true if DMARC record with reject policy exists for every domain if a .gov domain exists
+
+.EXAMPLE
+ Test-MtCisaDmarcAggregateCisa -Force
+
+ Returns true if DMARC record with reject policy exists for every domain
+#>
+
+Function Test-MtCisaDmarcAggregateCisa {
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param(
+ [switch]$Force
+ )
+
+ if(!(Test-MtConnection ExchangeOnline)){
+ Add-MtTestResultDetail -SkippedBecause NotConnectedExchange
+ return $null
+ }
+
+ $acceptedDomains = Get-AcceptedDomain
+ <# Parked domains should have DMARC with reject policy
+ $sendingDomains = $acceptedDomains | Where-Object {`
+ -not $_.SendingFromDomainDisabled
+ }
+ #>
+ $expandedDomains = @()
+ foreach($domain in $acceptedDomains){
+ #This regex does NOT capture for third level domain scenarios
+ #e.g., example.co.uk; example.ny.us;
+ $matchDomain = "(?:^|\.)(?'second'\w+.\w+$)"
+ $dmarcMatch = $domain.domainname -match $matchDomain
+ if($dmarcMatch){
+ $expandedDomains += $Matches.second
+ if($domain.domainname -ne $Matches.second){
+ $expandedDomains += $domain.domainname
+ }
+ }else{
+ $expandedDomains += $domain.domainname
+ }
+ }
+
+ if(!($expandedDomains -notlike "*.gov") -and !($Force)){
+ Add-MtTestResultDetail -SkippedBecause NotDotGovDomain
+ return $null
+ }
+
+ $dmarcRecords = @()
+ foreach($domain in $expandedDomains){
+ $dmarcRecord = Get-MailAuthenticationRecord -DomainName $domain -Records DMARC
+ $dmarcRecord | Add-Member -MemberType NoteProperty -Name "pass" -Value "Failed"
+ $dmarcRecord | Add-Member -MemberType NoteProperty -Name "reason" -Value ""
+
+ $checkType = $dmarcRecord.dmarcRecord.GetType().Name -eq "DMARCRecord"
+ $checkTarget = "reports@dmarc.cyber.dhs.gov" -in ($dmarcRecords.dmarcRecord.reportAggregate.mailAddress)
+
+ if($checkType -and $checkTarget){
+ $dmarcRecord.pass = "Passed"
+ }elseif($checkType -and -not $checkTarget){
+ $dmarcRecord.reason = "Missing CISA report target"
+ }else{
+ $dmarcRecord.reason = $dmarcRecord.dmarcRecord
+ }
+
+ $dmarcRecords += $dmarcRecord
+ }
+
+ if("Failed" -in $dmarcRecords.pass){
+ $testResult = $false
+ }else{
+ $testResult = $true
+ }
+
+ if($testResult){
+ $testResultMarkdown = "Well done. Your tenant's domains have DMARC aggregate reports sent to CISA.`n`n%TestResult%"
+ }else{
+ $testResultMarkdown = "Your tenant's domains do not have DMARC aggregate reports sent to CISA.`n`n%TestResult%"
+ }
+
+ $passResult = "✅ Pass"
+ $failResult = "❌ Fail"
+ $result = "| Domain | Result | Reason | Targets |`n"
+ $result += "| --- | --- | --- | --- |`n"
+ foreach ($item in $dmarcRecords) {
+ switch($item.pass){
+ "Passed" {$itemResult = $passResult}
+ "Failed" {$itemResult = $failResult}
+ }
+ $aggregates = $item.dmarcRecord.reportForensic.mailAddress
+ $aggregatesCount = ($aggregates|Measure-Object).Count
+ if($aggregatesCount -ge 3){
+ $aggregates = "$($aggregates[0])
$($aggregates[1])
"
+ $aggregates += "...$aggregatesCount targets"
+ }elseif(aggregatesCount -gt 1){
+ $aggregates = $aggregates -join "
"
+ }
+
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) | $($aggregates) |`n"
+ }
+
+ $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
+
+ Add-MtTestResultDetail -Result $testResultMarkdown
+
+ return $testResult
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordExist.md b/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordExist.md
new file mode 100644
index 00000000..7063f62c
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordExist.md
@@ -0,0 +1,24 @@
+A DMARC policy SHALL be published for every second-level domain.
+
+Rationale: Without a DMARC policy available for each domain, recipients may improperly handle SPF and DKIM failures, possibly enabling spoofed emails to reach end users' mailboxes. Publishing DMARC records at the second-level domain protects the second-level domains and all subdomains.
+
+#### Remediation action:
+
+DMARC is not configured through the Exchange admin center, but rather via DNS records hosted by the agency's domain. As such, implementation varies depending on how an agency manages its DNS records. See [Form the DMARC TXT record for your domain | Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/email-authentication-dmarc-configure?view=o365-worldwide#step-4-form-the-dmarc-txt-record-for-your-domain) for Microsoft guidance.
+
+A DMARC record published at the second-level domain will protect all subdomains. In other words, a DMARC record published for `example.com` will protect both `a.example.com` and `b.example.com`, but a separate record would need to be published for `c.example.gov`.
+
+To test your DMARC configuration, consider using one of many publicly available web-based tools. Additionally, DMARC records can be requested using the PowerShell tool `Resolve-DnsName`. For example:
+
+`Resolve-DnsName _dmarc.example.com txt`
+
+If DMARC is configured, a response resembling `v=DMARC1; p=reject; pct=100; rua=mailto:reports@dmarc.cyber.dhs.gov, mailto:reports@example.com; ruf=mailto:reports@example.com` will be returned, though by necessity, the contents of the record will vary by agency. In this example, the policy indicates all emails failing the SPF/DKIM checks are to be rejected and aggregate reports sent to reports@dmarc.cyber.dhs.gov and reports@example.com. Failure reports will be sent to reports@example.com.
+
+#### Related links
+
+* [Exchange admin center - Accepted domains](https://admin.exchange.microsoft.com/#/accepteddomains)
+* [CISA 4 Domain-Based Message Authentication, Reporting, and Conformance (DMARC) - MS.EXO.4.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo41v1)
+* [CISA ScubaGear Rego Reference](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Rego/EXOConfig.rego#L147)
+
+
+%TestResult%
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordExist.ps1 b/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordExist.ps1
new file mode 100644
index 00000000..9f0d1731
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordExist.ps1
@@ -0,0 +1,104 @@
+<#
+.SYNOPSIS
+ Checks state of DMARC records for all exo second level domains
+
+.DESCRIPTION
+
+ A DMARC policy SHALL be published for every second-level domain.
+
+.EXAMPLE
+ Test-MtCisaDmarcRecordExist
+
+ Returns true if DMARC record exists for all 2LD
+#>
+
+Function Test-MtCisaDmarcRecordExist {
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param()
+
+ if(!(Test-MtConnection ExchangeOnline)){
+ Add-MtTestResultDetail -SkippedBecause NotConnectedExchange
+ return $null
+ }
+
+ $acceptedDomains = Get-AcceptedDomain
+ <# Parked domains should have DMARC with reject policy
+ $sendingDomains = $acceptedDomains | Where-Object {`
+ -not $_.SendingFromDomainDisabled
+ }
+ #>
+
+ $dmarcRecords = @()
+ foreach($domain in $acceptedDomains){
+ #This regex does NOT capture for third level domain scenarios
+ #e.g., example.co.uk; example.ny.us;
+ $matchDomain = "(?:^|\.)(?'second'\w+.\w+$)"
+ $dmarcMatch = $domain.domainname -match $matchDomain
+ if($dmarcMatch){
+ $domainName = $Matches.second
+ }else{
+ $domainName = $domain.domainname
+ }
+
+ $dmarcRecord = Get-MailAuthenticationRecord -DomainName $domainName -Records DMARC
+ $dmarcRecord | Add-Member -MemberType NoteProperty -Name "pass" -Value "Failed"
+ $dmarcRecord | Add-Member -MemberType NoteProperty -Name "reason" -Value ""
+
+ if($dmarcRecord.dmarcRecord.GetType().Name -eq "DMARCRecord"){
+ $dmarcRecord.pass = "Passed"
+ }else{
+ $dmarcRecord.reason = $dmarcRecord.dmarcRecord
+ }
+
+ $dmarcRecords += $dmarcRecord
+ }
+
+ if("Failed" -in $dmarcRecords.pass){
+ $testResult = $false
+ }else{
+ $testResult = $true
+ }
+
+ if($testResult){
+ $testResultMarkdown = "Well done. Your tenant's second level domains have a DMARC record. Review report targets.`n`n%TestResult%"
+ }else{
+ $testResultMarkdown = "Your tenant's second level domains do not have a DMARC record.`n`n%TestResult%"
+ }
+
+ $passResult = "✅ Pass"
+ $failResult = "❌ Fail"
+ $result = "| Domain | Result | Reason | Targets |`n"
+ $result += "| --- | --- | --- | --- |`n"
+ foreach ($item in $dmarcRecords | Sort-Object -Property domain) {
+ switch($item.pass){
+ "Passed" {$itemResult = $passResult}
+ "Failed" {$itemResult = $failResult}
+ }
+ $aggregates = $item.dmarcRecord.reportForensic.mailAddress
+ $aggregatesCount = ($aggregates|Measure-Object).Count
+ if($aggregatesCount -ge 3){
+ $aggregates = "$($aggregates[0])
$($aggregates[1])
"
+ $aggregates += "...$aggregatesCount targets"
+ }elseif(aggregatesCount -gt 1){
+ $aggregates = $aggregates -join "
"
+ }
+ $forensics = $item.dmarcRecord.reportForensic.mailAddress
+ $forensicsCount = ($forensics|Measure-Object).Count
+ if($forensicsCount -ge 3){
+ $forensics = "$($forensics[0])
$($forensics[1])
"
+ $forensics += "...$forensicsCount targets"
+ }elseif(aggregatesCount -gt 1){
+ $forensics = $forensics -join "
"
+ }
+
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) | Aggregate Reports: $($aggregates) |`n"
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) | Forensic Reports: $($forensics) |`n"
+ }
+
+ $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
+
+ Add-MtTestResultDetail -Result $testResultMarkdown
+
+ return $testResult
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordReject.md b/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordReject.md
new file mode 100644
index 00000000..aa03bc49
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordReject.md
@@ -0,0 +1,17 @@
+The DMARC message rejection option SHALL be p=reject.
+
+Rationale: Of the three policy options (i.e., none, quarantine, and reject), reject provides the strongest protection. Reject is the level of protection required by BOD 18-01 for FCEB departments and agencies.
+
+#### Remediation action:
+
+* See MS.EXO.4.1v1 Instructions for an overview of how to publish and check a DMARC record.
+* Ensure the record published includes p=reject.
+
+#### Related links
+
+* [Exchange admin center - Accepted domains](https://admin.exchange.microsoft.com/#/accepteddomains)
+* [CISA 4 Domain-Based Message Authentication, Reporting, and Conformance (DMARC) - MS.EXO.4.2v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo42v1)
+* [CISA ScubaGear Rego Reference](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Rego/EXOConfig.rego#L176)
+
+
+%TestResult%
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordReject.ps1 b/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordReject.ps1
new file mode 100644
index 00000000..22411acf
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDmarcRecordReject.ps1
@@ -0,0 +1,98 @@
+<#
+.SYNOPSIS
+ Checks state of DMARC records for all exo domains
+
+.DESCRIPTION
+
+ The DMARC message rejection option SHALL be p=reject.
+
+.EXAMPLE
+ Test-MtCisaDmarcRecordExist
+
+ Returns true if DMARC record with reject policy exists for every domain
+#>
+
+Function Test-MtCisaDmarcRecordReject {
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param()
+
+ if(!(Test-MtConnection ExchangeOnline)){
+ Add-MtTestResultDetail -SkippedBecause NotConnectedExchange
+ return $null
+ }
+
+ $acceptedDomains = Get-AcceptedDomain
+ <# Parked domains should have DMARC with reject policy
+ $sendingDomains = $acceptedDomains | Where-Object {`
+ -not $_.SendingFromDomainDisabled
+ }
+ #>
+ $expandedDomains = @()
+ foreach($domain in $acceptedDomains){
+ #This regex does NOT capture for third level domain scenarios
+ #e.g., example.co.uk; example.ny.us;
+ $matchDomain = "(?:^|\.)(?'second'\w+.\w+$)"
+ $dmarcMatch = $domain.domainname -match $matchDomain
+ if($dmarcMatch){
+ $expandedDomains += $Matches.second
+ if($domain.domainname -ne $Matches.second){
+ $expandedDomains += $domain.domainname
+ }
+ }else{
+ $expandedDomains += $domain.domainname
+ }
+ }
+
+ $dmarcRecords = @()
+ foreach($domain in $expandedDomains){
+ $dmarcRecord = Get-MailAuthenticationRecord -DomainName $domain -Records DMARC
+ $dmarcRecord | Add-Member -MemberType NoteProperty -Name "pass" -Value "Failed"
+ $dmarcRecord | Add-Member -MemberType NoteProperty -Name "reason" -Value ""
+
+ $checkType = $dmarcRecord.dmarcRecord.GetType().Name -eq "DMARCRecord"
+
+ if($checkType -and $dmarcRecord.dmarcRecord.policy -eq "reject"){
+ $dmarcRecord.pass = "Passed"
+ }elseif($checkType -and $dmarcRecord.dmarcRecord.policy -ne "reject"){
+ $dmarcRecord.reason = "Policy is not reject"
+ }elseif($checkType -and $dmarcRecord.dmarcRecord.policySubdomain -in @("none","quarantine")){
+ $dmarcRecord.reason = "Subdomain policy is not reject"
+ }else{
+ $dmarcRecord.reason = $dmarcRecord.dmarcRecord
+ }
+
+ $dmarcRecords += $dmarcRecord
+ }
+
+ if("Failed" -in $dmarcRecords.pass){
+ $testResult = $false
+ }else{
+ $testResult = $true
+ }
+
+ if($testResult){
+ $testResultMarkdown = "Well done. Your tenant's domains have a DMARC record with reject policy. Review report targets.`n`n%TestResult%"
+ }else{
+ $testResultMarkdown = "Your tenant's domains do not have a DMARC record with reject policy.`n`n%TestResult%"
+ }
+
+ $passResult = "✅ Pass"
+ $failResult = "❌ Fail"
+ $result = "| Domain | Result | Reason | Policy | Subdomain Poliy |`n"
+ $result += "| --- | --- | --- | --- | --- |`n"
+ foreach ($item in $dmarcRecords) {
+ switch($item.pass){
+ "Passed" {$itemResult = $passResult}
+ "Failed" {$itemResult = $failResult}
+ }
+
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) | $($item.dmarcRecord.policy) | $($item.dmarcRecord.policySubdomain) |`n"
+ }
+
+ $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
+
+ Add-MtTestResultDetail -Result $testResultMarkdown
+
+ return $testResult
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDmarcReport.md b/powershell/public/CISA/exchange/Test-MtCisaDmarcReport.md
new file mode 100644
index 00000000..299ac89d
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDmarcReport.md
@@ -0,0 +1,20 @@
+An agency point of contact SHOULD be included for aggregate and failure reports.
+
+Rationale: Email spoofing attempts are not inherently visible to domain owners. DMARC provides a mechanism to receive reports of spoofing attempts. Including an agency point of contact gives the agency insight into attempts to spoof their domains.
+
+#### Remediation action:
+
+See MS.EXO.4.1v1 Instructions for an overview of how to publish and check a DMARC record. Ensure the record published includes:
+
+* A point of contact specific to your agency in the RUA field.
+* reports@dmarc.cyber.dhs.gov as one of the emails in the RUA field.
+* One or more agency-defined points of contact in the RUF field.
+
+#### Related links
+
+* [Exchange admin center - Accepted domains](https://admin.exchange.microsoft.com/#/accepteddomains)
+* [CISA 4 Domain-Based Message Authentication, Reporting, and Conformance (DMARC) - MS.EXO.4.4v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo44v1)
+* [CISA ScubaGear Rego Reference](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Rego/EXOConfig.rego#L252)
+
+
+%TestResult%
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaDmarcReport.ps1 b/powershell/public/CISA/exchange/Test-MtCisaDmarcReport.ps1
new file mode 100644
index 00000000..77daa49e
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaDmarcReport.ps1
@@ -0,0 +1,115 @@
+<#
+.SYNOPSIS
+ Checks state of DMARC records for all exo domains
+
+.DESCRIPTION
+
+ An agency point of contact SHOULD be included for aggregate and failure reports.
+
+.EXAMPLE
+ Test-MtCisaDmarcReport
+
+ Returns true if DMARC record inlcudes report targets within same domain
+#>
+
+Function Test-MtCisaDmarcReport {
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param()
+
+ if(!(Test-MtConnection ExchangeOnline)){
+ Add-MtTestResultDetail -SkippedBecause NotConnectedExchange
+ return $null
+ }
+
+ $acceptedDomains = Get-AcceptedDomain
+ <# Parked domains should have DMARC with reject policy
+ $sendingDomains = $acceptedDomains | Where-Object {`
+ -not $_.SendingFromDomainDisabled
+ }
+ #>
+ $expandedDomains = @()
+ foreach($domain in $acceptedDomains){
+ #This regex does NOT capture for third level domain scenarios
+ #e.g., example.co.uk; example.ny.us;
+ $matchDomain = "(?:^|\.)(?'second'\w+.\w+$)"
+ $dmarcMatch = $domain.domainname -match $matchDomain
+ if($dmarcMatch){
+ $expandedDomains += $Matches.second
+ if($domain.domainname -ne $Matches.second){
+ $expandedDomains += $domain.domainname
+ }
+ }else{
+ $expandedDomains += $domain.domainname
+ }
+ }
+
+ $dmarcRecords = @()
+ foreach($domain in $expandedDomains){
+ $dmarcRecord = Get-MailAuthenticationRecord -DomainName $domainName -Records DMARC
+ $dmarcRecord | Add-Member -MemberType NoteProperty -Name "pass" -Value "Failed"
+ $dmarcRecord | Add-Member -MemberType NoteProperty -Name "reason" -Value ""
+
+ $checkType = $dmarcRecord.dmarcRecord.GetType().Name -eq "DMARCRecord"
+ $hostsAggregate = $dmarcRecord.dmarcRecord.reportAggregate.mailAddress.Host
+ $hostsForensic = $dmarcRecord.dmarcRecord.reportForensic.mailAddress.Host
+
+ if($checkType -and $domain -in $hostsAggregate -and $domain -in $hostsForensic){
+ $dmarcRecord.pass = "Passed"
+ }elseif($checkType){
+ $dmarcRecord.reason = "No target in domain"
+ }else{
+ $dmarcRecord.reason = $dmarcRecord.dmarcRecord
+ }
+
+ $dmarcRecords += $dmarcRecord
+ }
+
+ if("Failed" -in $dmarcRecords.pass){
+ $testResult = $false
+ }else{
+ $testResult = $true
+ }
+
+ if($testResult){
+ $testResultMarkdown = "Well done. Your tenant's domains have in domain report targets. Review report targets.`n`n%TestResult%"
+ }else{
+ $testResultMarkdown = "Your tenant's second level domains do not have in domain report targets.`n`n%TestResult%"
+ }
+
+ $passResult = "✅ Pass"
+ $failResult = "❌ Fail"
+ $result = "| Domain | Result | Reason | Targets |`n"
+ $result += "| --- | --- | --- | --- |`n"
+ foreach ($item in $dmarcRecords | Sort-Object -Property domain) {
+ switch($item.pass){
+ "Passed" {$itemResult = $passResult}
+ "Failed" {$itemResult = $failResult}
+ }
+ $aggregates = $item.dmarcRecord.reportForensic.mailAddress
+ $aggregatesCount = ($aggregates|Measure-Object).Count
+ if($aggregatesCount -ge 3){
+ $aggregates = "$($aggregates[0])
$($aggregates[1])
"
+ $aggregates += "...$aggregatesCount targets"
+ }elseif(aggregatesCount -gt 1){
+ $aggregates = $aggregates -join "
"
+ }
+ $forensics = $item.dmarcRecord.reportForensic.mailAddress
+ $forensicsCount = ($forensics|Measure-Object).Count
+ if($forensicsCount -ge 3){
+ $forensics = "$($forensics[0])
$($forensics[1])
"
+ $forensics += "...$forensicsCount targets"
+ }elseif(aggregatesCount -gt 1){
+ $forensics = $forensics -join "
"
+ }
+
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) | Aggregate Reports: $($aggregates) |`n"
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) | Forensic Reports: $($forensics) |`n"
+ }
+
+ $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
+
+ Add-MtTestResultDetail -Result $testResultMarkdown
+
+ return $testResult
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaSpfDirective.md b/powershell/public/CISA/exchange/Test-MtCisaSpfDirective.md
new file mode 100644
index 00000000..25efcd09
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaSpfDirective.md
@@ -0,0 +1,22 @@
+An SPF policy SHALL be published for each domain, designating only these addresses as approved senders.
+
+Rationale: An adversary may modify the `FROM` field of an email such that it appears to be a legitimate email sent by an agency, facilitating phishing attacks. Publishing an SPF policy for each agency domain mitigates forged `FROM` fields by providing a means for recipients to detect emails spoofed in this way. SPF is required for FCEB departments and agencies by Binding Operational Directive (BOD) 18-01, "Enhance Email and Web Security".
+
+#### Remediation action:
+
+SPF is not configured through the Exchange admin center, but rather via DNS records hosted by the agency's domain. Thus, the exact steps needed to set up SPF varies from agency to agency. See [Add or edit an SPF TXT record to help prevent email spam (Outlook, Exchange Online) | Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365/admin/get-help-with-domains/create-dns-records-at-any-dns-hosting-provider?view=o365-worldwide#add-or-edit-an-spf-txt-record-to-help-prevent-email-spam-outlook-exchange-online) for more details.
+
+To test your SPF configuration, consider using a web-based tool, such as those listed under [How can I validate SPF records for my domain? | Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365/admin/setup/domains-faq?view=o365-worldwide#how-can-i-validate-spf-records-for-my-domain). Additionally, SPF records can be requested using the PowerShell tool `Resolve-DnsName`. For example:
+
+`Resolve-DnsName example.onmicrosoft.com txt`
+
+If SPF is configured, you will see a response resembling `v=spf1 include:spf.protection.outlook.com -all` returned; though by necessity, the contents of the SPF policy may vary by agency. In this example, the SPF policy indicates the IP addresses listed by the policy for "spf.protection.outlook.com" are the only approved senders for "example.onmicrosoft.com." These IPs can be determined via an additional SPF lookup, this time for "spf.protection.outlook.com." Ensure the IP addresses listed as approved senders for your domain are those identified for MS.EXO.2.1v1. [See SPF TXT record syntax for Microsoft 365 | Microsoft Learn](https://learn.microsoft.com/en-us/microsoft-365/security/office-365-security/email-authentication-anti-spoofing?view=o365-worldwide#spf-txt-record-syntax-for-microsoft-365) for a more in-depth discussion of SPF record syntax.
+
+#### Related links
+
+* [Exchange admin center - Accepted domains](https://admin.exchange.microsoft.com/#/accepteddomains)
+* [CISA 2 Sender Policy Framework - MS.EXO.2.2v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo22v1)
+* [CISA ScubaGear Rego Reference](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Rego/EXOConfig.rego#L75)
+
+
+%TestResult%
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaSpfDirective.ps1 b/powershell/public/CISA/exchange/Test-MtCisaSpfDirective.ps1
new file mode 100644
index 00000000..116f7012
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaSpfDirective.ps1
@@ -0,0 +1,115 @@
+<#
+.SYNOPSIS
+ Checks state of SPF records for all exo domains
+
+.DESCRIPTION
+
+ An SPF policy SHALL be published for each domain, designating only these addresses as approved senders.
+
+.EXAMPLE
+ Test-MtCisaSpfDirective
+
+ Returns true if SPF record exists and has at least one directive
+#>
+
+Function Test-MtCisaSpfDirective {
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param()
+
+ if(!(Test-MtConnection ExchangeOnline)){
+ Add-MtTestResultDetail -SkippedBecause NotConnectedExchange
+ return $null
+ }
+
+ $acceptedDomains = Get-AcceptedDomain
+ $sendingDomains = $acceptedDomains | Where-Object {`
+ -not $_.SendingFromDomainDisabled
+ }
+
+ $spfRecords = @()
+ foreach($domain in $sendingDomains){
+ $spfRecord = Get-MailAuthenticationRecord -DomainName $domain.DomainName -Records SPF
+ $spfRecord | Add-Member -MemberType NoteProperty -Name "pass" -Value "Failed"
+ $spfRecord | Add-Member -MemberType NoteProperty -Name "reason" -Value ""
+
+ $directives = ($spfRecord.spfRecord.terms|Where-Object {`
+ $_.mechanismTarget -ne ""
+ }).directive
+
+ $check = "include:spf.protection.outlook.com" -in $directives
+
+ if(($directives|Measure-Object).Count -ge 1 -and $check){
+ $spfRecord.pass = "Passed"
+ $spfRecord.reason = "1+ mechanism targets"
+ }elseif(($directives|Measure-Object).Count -ge 1 -and -not $check){
+ $spfRecord.reason = "No EXO directive"
+ }elseif($spfRecord.spfRecord.terms[-1].modifier -eq "redirect"){
+ $spfRecord.pass = "Skipped"
+ $spfRecord.reason = "Redirect modifier"
+ }else{
+ $spfRecord.reason = "No mechanism targets"
+ }
+
+ #Hacky sort, doesn't handle IPv6
+ #$spfRecord.spfLookups.IPAddress|sort -Property {[system.version]($_ -replace "\/\d{1,3}$","")}
+ #Proper but will need to update Resolve-SPFRecord
+ #Too: https://learn.microsoft.com/en-us/dotnet/api/system.net.ipnetwork
+ #[ipaddress]::HostToNetworkOrder(([ipaddress]$_).address)
+
+ $spfRecords += $spfRecord
+ }
+
+ if("Failed" -in $spfRecords.pass){
+ $testResult = $false
+ }else{
+ $testResult = $true
+ }
+
+ if($testResult){
+ $testResultMarkdown = "Well done. Your tenant's domains have at least 1 directives with specific mechanism targets, review authorized senders for accuracy.`n`n%TestResult%"
+ }else{
+ $testResultMarkdown = "Your tenant's domains do not restrict authorized senders with SPF fully. Ensure authorized senders are specified.`n`n%TestResult%"
+ }
+
+ $passResult = "✅ Pass"
+ $failResult = "❌ Fail"
+ $skipResult = "🗄️ Skip"
+ $result = "| Domain | Result | Reason | Directives |`n"
+ $result += "| --- | --- | --- | --- |`n"
+ foreach ($item in $spfRecords | Sort-Object -Property domain) {
+ switch($item.pass){
+ "Passed" {$itemResult = $passResult}
+ "Skipped" {$itemResult = $skipResult}
+ "Failed" {$itemResult = $failResult}
+ }
+ $itemDirectives = ($item.spfRecord.terms|Where-Object {`
+ $_.mechanismTarget -ne ""
+ }).directive
+ $itemDirectiveCount = ($itemDirectives|Measure-Object).Count
+ switch($itemDirectiveCount){
+ 0 {
+ $itemList = ""
+ }
+ 1 {
+ $itemList = "$($itemDirectives)"
+ }
+ 2 {
+ $itemList = "$($itemDirectives[0])
"
+ $itemList += "$($itemDirectives[1])"
+ }
+ Default {
+ $itemList = "$($itemDirectives[0])
"
+ $itemList += "$($itemDirectives[1])
"
+ $itemList += "...$($itemDirectiveCount) directives"
+ }
+ }
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) | $($itemList) |`n"
+ }
+
+ $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
+
+ Add-MtTestResultDetail -Result $testResultMarkdown
+
+ return $testResult
+}
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaSpfRestriction.md b/powershell/public/CISA/exchange/Test-MtCisaSpfRestriction.md
new file mode 100644
index 00000000..6d50a5c7
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaSpfRestriction.md
@@ -0,0 +1,18 @@
+A list of approved IP addresses for sending mail SHALL be maintained.
+
+Rationale: Failing to maintain an accurate list of authorized IP addresses may result in spoofed email messages or failure to deliver legitimate messages when SPF is enabled. Maintaining such a list helps ensure that unauthorized servers sending spoofed messages can be detected, and permits message delivery from legitimate senders.
+
+#### Remediation action:
+
+* Identify any approved senders specific to your agency.
+* Perform regular review of SPF record and update as necessary.
+* Additionally, see [External DNS records required for SPF](https://learn.microsoft.com/en-us/microsoft-365/enterprise/external-domain-name-system-records?view=o365-worldwide#external-dns-records-required-for-spf) for inclusions required for Microsoft to send email on behalf of your domain.
+
+#### Related links
+
+* [Exchange admin center - Accepted domains](https://admin.exchange.microsoft.com/#/accepteddomains)
+* [CISA 2 Sender Policy Framework - MS.EXO.2.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo21v1)
+* [CISA ScubaGear Rego Reference](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/Rego/EXOConfig.rego#L58)
+
+
+%TestResult%
\ No newline at end of file
diff --git a/powershell/public/CISA/exchange/Test-MtCisaSpfRestriction.ps1 b/powershell/public/CISA/exchange/Test-MtCisaSpfRestriction.ps1
new file mode 100644
index 00000000..b46398c4
--- /dev/null
+++ b/powershell/public/CISA/exchange/Test-MtCisaSpfRestriction.ps1
@@ -0,0 +1,106 @@
+<#
+.SYNOPSIS
+ Checks state of SPF records for all exo domains
+
+.DESCRIPTION
+
+ A list of approved IP addresses for sending mail SHALL be maintained.
+
+.EXAMPLE
+ Test-MtCisaSpfRestriction
+
+ Returns true if SPF record exists and has a fail all modifier for all exo domains
+#>
+
+Function Test-MtCisaSpfRestriction {
+ [CmdletBinding()]
+ [OutputType([bool])]
+ param()
+
+ if(!(Test-MtConnection ExchangeOnline)){
+ Add-MtTestResultDetail -SkippedBecause NotConnectedExchange
+ return $null
+ }
+
+ $acceptedDomains = Get-AcceptedDomain
+ <# Parked domains should have SPF ending in -all too
+ $sendingDomains = $acceptedDomains | Where-Object {`
+ -not $_.SendingFromDomainDisabled
+ }
+ #>
+
+ $spfRecords = @()
+ foreach($domain in $acceptedDomains){
+ $spfRecord = Get-MailAuthenticationRecord -DomainName $domain.DomainName -Records SPF
+ $spfRecord | Add-Member -MemberType NoteProperty -Name "pass" -Value "Failed"
+ $spfRecord | Add-Member -MemberType NoteProperty -Name "reason" -Value ""
+
+ if($spfRecord.spfRecord.terms[-1].directive -eq "-all"){
+ $spfRecord.pass = "Passed"
+ $spfRecord.reason = "Last directive is '-all'"
+ }elseif($spfRecord.spfRecord.terms[-1].modifier -eq "redirect"){
+ $spfRecord.pass = "Skipped"
+ $spfRecord.reason = "Redirect modifier"
+ }else{
+ $spfRecord.reason = "Last directive is not '-all'"
+ }
+
+ #Hacky sort, doesn't handle IPv6
+ #$spfRecord.spfLookups.IPAddress|sort -Property {[system.version]($_ -replace "\/\d{1,3}$","")}
+ #Proper but will need to update Resolve-SPFRecord
+ #Too: https://learn.microsoft.com/en-us/dotnet/api/system.net.ipnetwork
+ #[ipaddress]::HostToNetworkOrder(([ipaddress]$_).address)
+
+ $spfRecords += $spfRecord
+ }
+
+ if("Failed" -in $spfRecords.pass){
+ $testResult = $false
+ }else{
+ $testResult = $true
+ }
+
+ if($testResult){
+ $testResultMarkdown = "Well done. Your tenant's domains have a restricted SPF, review authorized senders for accuracy.`n`n%TestResult%"
+ }else{
+ $testResultMarkdown = "Your tenant's domains do not restrict authorized senders with SPF fully. Ensure all domain's SPF records end in '-all'.`n`n%TestResult%"
+ }
+
+ $passResult = "✅ Pass"
+ $failResult = "❌ Fail"
+ $skipResult = "🗄️ Skip"
+ $result = "| Domain | Result | Reason | Addresses |`n"
+ $result += "| --- | --- | --- | --- |`n"
+ foreach ($item in $spfRecords | Sort-Object -Property domain) {
+ switch($item.pass){
+ "Passed" {$itemResult = $passResult}
+ "Skipped" {$itemResult = $skipResult}
+ "Failed" {$itemResult = $failResult}
+ }
+ $itemAddressCount = ($item.spfLookups.IPAddress|Measure-Object).Count
+ switch($itemAddressCount){
+ 0 {
+ $itemAddressList = ""
+ }
+ 1 {
+ $itemAddressList = "$($item.spfLookups.IPAddress[0])"
+ }
+ 2 {
+ $itemAddressList = "$($item.spfLookups.IPAddress[0])
"
+ $itemAddressList += "$($item.spfLookups.IPAddress[1])"
+ }
+ Default {
+ $itemAddressList = "$($item.spfLookups.IPAddress[0])
"
+ $itemAddressList += "$($item.spfLookups.IPAddress[1])
"
+ $itemAddressList += "...$($itemAddressCount-2) addresses"
+ }
+ }
+ $result += "| $($item.domain) | $($itemResult) | $($item.reason) | $($itemAddressList) |`n"
+ }
+
+ $testResultMarkdown = $testResultMarkdown -replace "%TestResult%", $result
+
+ Add-MtTestResultDetail -Result $testResultMarkdown
+
+ return $testResult
+}
\ No newline at end of file
diff --git a/powershell/public/Clear-MtDnsCache.ps1 b/powershell/public/Clear-MtDnsCache.ps1
new file mode 100644
index 00000000..99c73483
--- /dev/null
+++ b/powershell/public/Clear-MtDnsCache.ps1
@@ -0,0 +1,22 @@
+<#
+.SYNOPSIS
+ Resets the local cache of DNS lookups. Use this if you need to force a refresh of the cache in the current session.
+
+.DESCRIPTION
+ By default all DNS responses are cached and re-used for the duration of the session.
+
+ Use this function to clear the cache and force a refresh of the data.
+
+.EXAMPLE
+ Clear-MtDnsCache
+
+ This example clears the cache of all DNS lookups.
+#>
+function Clear-MtDnsCache {
+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification='Setting module level variable')]
+ param()
+
+ Write-Verbose -Message "Clearing the results cached from DNS lookups in this session"
+
+ $__MtSession.DnsCache = @()
+}
\ No newline at end of file
diff --git a/tests/CISA/exchange/Test-MtCisaDkim.Tests.ps1 b/tests/CISA/exchange/Test-MtCisaDkim.Tests.ps1
new file mode 100644
index 00000000..edf37df1
--- /dev/null
+++ b/tests/CISA/exchange/Test-MtCisaDkim.Tests.ps1
@@ -0,0 +1,9 @@
+Describe "CISA SCuBA" -Tag "MS.EXO", "MS.EXO.3.1", "CISA", "Security", "All" {
+ It "MS.EXO.3.1: DKIM SHOULD be enabled for all domains." {
+ $cisaDkim = Test-MtCisaDkim
+
+ if ($null -ne $cisaDkim) {
+ $cisaDkim | Should -Be $true -Because "DKIM record should exist and be configured."
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CISA/exchange/Test-MtCisaDmarcAggregateCisa.Tests.ps1 b/tests/CISA/exchange/Test-MtCisaDmarcAggregateCisa.Tests.ps1
new file mode 100644
index 00000000..bf409549
--- /dev/null
+++ b/tests/CISA/exchange/Test-MtCisaDmarcAggregateCisa.Tests.ps1
@@ -0,0 +1,9 @@
+Describe "CISA SCuBA" -Tag "MS.EXO", "MS.EXO.4.3", "CISA", "Security", "All" {
+ It "MS.EXO.4.3: The DMARC point of contact for aggregate reports SHALL include reports@dmarc.cyber.dhs.gov." {
+ $cisaDmarcAggregateCisa = Test-MtCisaDmarcAggregateCisa
+
+ if ($null -ne $cisaDmarcAggregateCisa) {
+ $cisaDmarcAggregateCisa | Should -Be $true -Because "DMARC record includes proper aggregate target."
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CISA/exchange/Test-MtCisaDmarcRecordExist.Tests.ps1 b/tests/CISA/exchange/Test-MtCisaDmarcRecordExist.Tests.ps1
new file mode 100644
index 00000000..a17096fd
--- /dev/null
+++ b/tests/CISA/exchange/Test-MtCisaDmarcRecordExist.Tests.ps1
@@ -0,0 +1,9 @@
+Describe "CISA SCuBA" -Tag "MS.EXO", "MS.EXO.4.1", "CISA", "Security", "All" {
+ It "MS.EXO.4.1: A DMARC policy SHALL be published for every second-level domain." {
+ $cisaDmarcRecordExist = Test-MtCisaDmarcRecordExist
+
+ if ($null -ne $cisaDmarcRecordExist) {
+ $cisaDmarcRecordExist | Should -Be $true -Because "DMARC record should exist."
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CISA/exchange/Test-MtCisaDmarcRecordReject.Tests.ps1 b/tests/CISA/exchange/Test-MtCisaDmarcRecordReject.Tests.ps1
new file mode 100644
index 00000000..dd61773c
--- /dev/null
+++ b/tests/CISA/exchange/Test-MtCisaDmarcRecordReject.Tests.ps1
@@ -0,0 +1,9 @@
+Describe "CISA SCuBA" -Tag "MS.EXO", "MS.EXO.4.2", "CISA", "Security", "All" {
+ It "MS.EXO.4.2: The DMARC message rejection option SHALL be p=reject." {
+ $cisaDmarcRecordReject = Test-MtCisaDmarcRecordReject
+
+ if ($null -ne $cisaDmarcRecordReject) {
+ $cisaDmarcRecordReject | Should -Be $true -Because "DMARC record policy should be reject."
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CISA/exchange/Test-MtCisaDmarcReport.ps1 b/tests/CISA/exchange/Test-MtCisaDmarcReport.ps1
new file mode 100644
index 00000000..39291d55
--- /dev/null
+++ b/tests/CISA/exchange/Test-MtCisaDmarcReport.ps1
@@ -0,0 +1,9 @@
+Describe "CISA SCuBA" -Tag "MS.EXO", "MS.EXO.4.4", "CISA", "Security", "All" {
+ It "MS.EXO.4.4: An agency point of contact SHOULD be included for aggregate and failure reports." {
+ $cisaDmarcReport = Test-MtCisaDmarcReport
+
+ if ($null -ne $cisaDmarcReport) {
+ $cisaDmarcReport | Should -Be $true -Because "DMARC report targets should exist."
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CISA/exchange/Test-MtCisaSpfDirective.Tests.ps1 b/tests/CISA/exchange/Test-MtCisaSpfDirective.Tests.ps1
new file mode 100644
index 00000000..efd5df54
--- /dev/null
+++ b/tests/CISA/exchange/Test-MtCisaSpfDirective.Tests.ps1
@@ -0,0 +1,9 @@
+Describe "CISA SCuBA" -Tag "MS.EXO", "MS.EXO.2.2", "CISA", "Security", "All" {
+ It "MS.EXO.2.2: An SPF policy SHALL be published for each domain, designating only these addresses as approved senders." {
+ $cisaSpfDirective = Test-MtCisaSpfDirective
+
+ if ($null -ne $cisaSpfDirective) {
+ $cisaSpfDirective | Should -Be $true -Because "SPF record should restrict authorized senders."
+ }
+ }
+}
\ No newline at end of file
diff --git a/tests/CISA/exchange/Test-MtCisaSpfRestriction.Tests.ps1 b/tests/CISA/exchange/Test-MtCisaSpfRestriction.Tests.ps1
new file mode 100644
index 00000000..1803f16b
--- /dev/null
+++ b/tests/CISA/exchange/Test-MtCisaSpfRestriction.Tests.ps1
@@ -0,0 +1,9 @@
+Describe "CISA SCuBA" -Tag "MS.EXO", "MS.EXO.2.1", "CISA", "Security", "All" {
+ It "MS.EXO.2.1: A list of approved IP addresses for sending mail SHALL be maintained." {
+ $cisaSpfRestriction = Test-MtCisaSpfRestriction
+
+ if ($null -ne $cisaSpfRestriction) {
+ $cisaSpfRestriction | Should -Be $true -Because "SPF record should restrict authorized senders."
+ }
+ }
+}
\ No newline at end of file
diff --git a/website/docs/tests/cisa/exo.md b/website/docs/tests/cisa/exo.md
index 5c6c27dc..2b439d6e 100644
--- a/website/docs/tests/cisa/exo.md
+++ b/website/docs/tests/cisa/exo.md
@@ -20,6 +20,13 @@ See the [Installation guide](/docs/installation#optional-modules-and-permissions
| Cmdlet Name | CISA Control ID (Link) |
| - | - |
| Test-MtCisaAutoExternalForwarding | [MS.EXO.1.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo11v1) |
+| Test-MtCisaSpfRestriction | [MS.EXO.2.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo21v1) |
+| Test-MtCisaSpfDirective | [MS.EXO.2.2v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo22v1) |
+| Test-MtCisaDkim | [MS.EXO.3.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo31v1) |
+| Test-MtCisaDmarcRecordExist | [MS.EXO.4.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo41v1) |
+| Test-MtCisaDmarcRecordReject | [MS.EXO.4.2v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo42v1) |
+| Test-MtCisaDmarcAggregateCisa | [MS.EXO.4.3v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo43v1) |
+| Test-MtCisaDmarcReport | [MS.EXO.4.4v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo44v1) |
| Test-MtCisaSmtpAuthentication | [MS.EXO.5.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo51v1) |
| Test-MtCisaContactSharing | [MS.EXO.6.1v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo61v1) |
| Test-MtCisaCalendarSharing | [MS.EXO.6.2v1](https://github.com/cisagov/ScubaGear/blob/main/PowerShell/ScubaGear/baselines/exo.md#msexo62v1) |