Skip to content

Commit

Permalink
Merge pull request #271 from Snozzberries/exoDns
Browse files Browse the repository at this point in the history
Mail Authentication Functions to support CISA EXO SPF/DKIM/DMARC Checks
  • Loading branch information
merill authored Jul 2, 2024
2 parents a2334e9 + b1caa85 commit ad6a4a6
Show file tree
Hide file tree
Showing 32 changed files with 1,837 additions and 1 deletion.
8 changes: 7 additions & 1 deletion powershell/Maester.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 = @()
Expand Down
1 change: 1 addition & 0 deletions powershell/Maester.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ $__MtSession = @{
GraphBaseUri = $null
TestResultDetail = @{}
Connections = @()
DnsCache = @()
}
New-Variable -Name __MtSession -Value $__MtSession -Scope Script -Force

Expand Down
1 change: 1 addition & 0 deletions powershell/internal/Clear-ModuleVariable.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit ad6a4a6

Please sign in to comment.