-
Notifications
You must be signed in to change notification settings - Fork 115
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #271 from Snozzberries/exoDns
Mail Authentication Functions to support CISA EXO SPF/DKIM/DMARC Checks
- Loading branch information
Showing
32 changed files
with
1,837 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
103 changes: 103 additions & 0 deletions
103
powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordDkim.ps1
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
234 changes: 234 additions & 0 deletions
234
powershell/public/CISA/exchange/ConvertFrom-MailAuthenticationRecordDmarc.ps1
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
Oops, something went wrong.