Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Mail Authentication Functions to support CISA EXO SPF/DKIM/DMARC Checks #271

Merged
merged 25 commits into from
Jul 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading