mailsecchk.sh
#!/bin/sh
# Copyright (c) 2022, Jeffrey Bencteux
# All rights reserved.

# This source code is licensed under the GPLv3 license found in the
# LICENSE file in the root directory of this source tree.

# shellcheck disable=SC3043

usage()
{
    echo "Usage: $0 [OPTIONS]..."
    echo "check mail security of a given domain"
    echo
    echo "arguments:"
    echo "  -d domain to be checked"
    echo "  -h display this help and exit"
    echo "  -l log file to output to"
    echo "  -p extract DKIM public key if found"
    echo "  -r SPF recursive tests"
}

log()
{
	printf '%b\n' "$1"

	if [ "$logfile" != "" ]; then
		echo "$1" >> "$logfile"
	fi
}

print_good()
{
	printf '\033[1;32m[+]\033[0m %s\n' "$1"

	if [ "$logfile" != "" ]; then
		echo "[+] $1" >> "$logfile"
	fi
}


print_bad()
{
	printf '\033[1;31m[-]\033[0m %s\n' "$1"

	if [ "$logfile" != "" ]; then
		echo "[-] $1" >> "$logfile"
	fi
}

print_medium()
{
	printf '\033[1;33m[~]\033[0m %s\n' "$1"

	if [ "$logfile" != "" ]; then
		echo "[~] $1" >> "$logfile"
	fi
}

print_info()
{
	printf '\033[1;34m[I]\033[0m %s\n' "$1"

	if [ "$logfile" != "" ]; then
		echo "[I] $1" >> "$logfile"
	fi
}

d=""
spf_recursive=0
spf_specific_found=0
dkim_selectors_file="./dkim_selectors.txt"
dkim_extract=0
dkim_key_outfile="./dkim_pubkey.pem"
# Quite a hard choice of what is a good key size here, for now keeping to < 2048 bits
dkim_key_minsize=2048
specific=""
bimi_selectors_file="./bimi_selectors.txt"

while getopts "d:hl:pr" o; do
    case "${o}" in
	d)
		d="${OPTARG}"
		;;
	h)
		usage
		exit 0
		;;
	l)
		logfile="${OPTARG}"
		;;
	p)
		dkim_extract=1
		;;
	r)
		spf_recursive=1
		;;
	*)
		usage
		exit 1
		;;
    esac
done
shift $((OPTIND-1))

log "  _   .-')      ('-.                         .-')      ('-.                        ('-. .-..-. .-')   "
log " ( '.( OO )_   ( OO ).-.                    ( OO ).  _(  OO)                      ( OO )  /\\  ( OO )  "
log " ,--.   ,--.) / . --. /  ,-.-')  ,--.     (_)---\\_)(,------.   .-----.   .-----. ,--. ,--.,--. ,--.  "
log " |   \`.'   |  | \\-.  \\   |  |OO) |  |.-') /    _ |  |  .---'  '  .--./  '  .--./ |  | |  ||  .'   /  "
log " |         |.-'-'  |  |  |  |  \\ |  | OO )\\  :\` \`.  |  |      |  |('-.  |  |('-. |   .|  ||      /, "
log " |  |'.'|  | \\| |_.'  |  |  |(_/ |  |\`-' | '..\`''.)(|  '--.  /_) |OO  )/_) |OO  )|       ||     ' _) "
log " |  |   |  |  |  .-.  | ,|  |_.'(|  '---.'.-._)   \\ |  .--'  ||  |\`-'| ||  |\`-'| |  .-.  ||  .   \\   "
log " |  |   |  |  |  | |  |(_|  |    |      | \\       / |  \`---.(_'  '--'\\(_'  '--'\\ |  | |  ||  |\\   \\  "
log " \`--'   \`--'  \`--' \`--'  \`--'    \`------'  \`-----'  \`------'   \`-----'   \`-----' \`--' \`--'\`--' '--'  "

get_mx()
{
	domain="$1"

	mx=$(dig +short mx "$domain")
}

has_mx_specific()
{
	local name="$1"
	local full_name="$2"
	local mx_dn="$3"

	if echo "$mx" | grep -q "$mx_dn"; then
		print_info "It looks like domain is using $full_name, including specific tests."
		specific="$name"
	fi
}

get_spf()
{
	local domain="$1"

	spf=$(dig +short txt "$domain" | grep 'spf')
}

has_spf()
{
	local spf="$1"

	if [ "$spf" = "" ]; then
		print_bad "No SPF for domain"
	else
		print_good "domain has a SPF record"
	fi
}

spf_version()
{
	local spf="$1"

	if [ "$spf" = "" ]; then
		return
	fi

	if echo "$spf" | grep -Eqv "^\"(v=spf1[ ]|^\"v=spf1$)"; then
		print_bad "SPF version is incorrect"
	else
		print_good "SPF version is correct"
	fi
}

loose_spf()
{
	local spf="$1"

	if [ "$spf" = "" ]; then
		return
	fi

	if echo "$spf" | grep -vq "\-all"; then
		print_bad "SPF not in FAIL mode (\"-all\")"
	else
		print_good "SPF is in FAIL mode (\"-all\")"
	fi
}

spf_include_domain()
{
	local spf="$1"
	local name="$2"
	local full_name="$3"
	local include="$4"
	local found_in_mx="$5"

	if [ "$spf" = "" ]; then
		return
	fi

	if [ "$found_in_mx" != "$name" ]; then
		return
	fi

	if echo "$spf" | grep -q "include:$include"; then
		print_good "SPF includes $name one ($include)"
		spf_specific_found=1
		spf_specific_include="$include"
	fi
}

spf_includes_recursive()
{
	local spf="$1"
	local domain="$2"
	local specific="$3"

	if [ "$spf_recursive" -eq 0 ]; then
		return
	fi

	if [ "$spf" = "" ]; then
		return
	fi

	# Unsure this weak parsing catches all cases
	spf_includes=$(echo "$spf" | grep -Eo "include:[^ ]+" | sed 's/include://g')

	if [ "$spf_includes" != "" ]; then
		print_info "SPF recursive check for $domain"
	fi


	for include in $spf_includes; do
		include_res=$(dig +short txt "$include" | grep "spf")

		if [ "$include_res" = "" ]; then
			print_bad "SPF include \"$include\" does not resolve to a valid DNS TXT record"
		else
			print_info "\"$include\": $include_res"
			spf_include_domain "$include_res" "m365" "Microsoft 365" "spf.protection.outlook.com" "$specific"
			spf_include_domain "$include_res" "google" "Google Workspace" "_spf.google.com" "$specific"
			spf_includes_recursive "$include_res" "$include" "$specific"
		fi
	done
}

# DMARC checks
get_dmarc()
{
	local domain="$1"

	dmarc=$(dig +short txt "_dmarc.$domain")
}

has_dmarc()
{
	local dmarc="$1"

	if [ "$dmarc" = "" ]; then
		print_bad "No dmarc for domain"
	else
		print_good "domain has a DMARC record"
	fi
}

dmarc_version()
{
	local dmarc="$1"

	if [ "$dmarc" = "" ]; then
		return
	fi


	if echo "$dmarc" | grep -Eq "^\"v=DMARC1"; then
		print_good "DMARC version is correct"
	else
		print_bad "DMARC version is incorrect, all record will be ignored by receivers"
	fi
}

loose_dmarc_policy()
{
	local dmarc="$1"

	if [ "$dmarc" = "" ]; then
		return
	fi

	loose_dmarc_p=0

	if echo "$dmarc" | grep -Eq "[ ;]p=(reject|quarantine)"; then
		print_good "DMARC policy is correct"
	else
		print_bad "DMARC policy not set to \"reject\" or \"quarantine\""
		loose_dmarc_p=1
	fi
}

loose_dmarc_subpolicy()
{
	local dmarc="$1"

	if [ "$dmarc" = "" ]; then
		return
	fi


	if echo "$dmarc" | grep "sp=" | grep -vEq "sp=(reject|quarantine)"; then
		print_bad "DMARC subpolicy not set to \"reject\" or \"quarantine\""
	else
		if [ "$loose_dmarc_p" -eq 1 ]; then
			print_bad "DMARC subpolicy not set to \"reject\" or \"quarantine\""
		else
			print_good "DMARC subpolicy is correct"
		fi
	fi
}

dmarc_pct()
{
	local dmarc="$1"

	if [ "$dmarc" = "" ]; then
		return
	fi

	if echo "$dmarc" | grep "pct=" | grep -vEq "pct=100"; then
		print_bad "DMARC sample percentage not set to 100"
	fi
}

dmarc_rua_ruf()
{
	local dmarc="$1"

	if [ "$dmarc" = "" ]; then
		return
	fi

	rua=$(echo "$dmarc" | grep -oE "rua=[^ ]+";)
	ruf=$(echo "$dmarc" | grep -oE "ruf=[^ ]+";)

	if [ "$rua" = "" ]; then
		print_medium "DMARC no aggregate report URI (RUA) defined"
	else
		if echo "$rua" | grep -Eo "@[^ ,\"]+" | grep -vq "$d"; then
			print_medium "DMARC RUA is external to the domain, please review manually"
		fi
	fi

	if [ "$ruf" = "" ]; then
		print_medium "DMARC no forensic report URI (RUF) defined"
	else
		if echo "$ruf" | grep -Eo "@[^ ,\"]+" | grep -vq "$d"; then
			print_medium "DMARC RUF is external to the domain, please review manually"
		fi
	fi
}

dmarc_fo()
{
	local dmarc="$1"

	if [ "$dmarc" = "" ]; then
		return
	fi

	fo=$(echo "$dmarc" | grep -oE "fo=[^ ;]")

	# FO defaults to 0, handling below the case where it is not specified but there is a ruf
	if [ "$fo" = "" ]; then
		if [ "$ruf" != "" ]; then
			print_medium "Failure reporting options set to report only if all mechanisms fail (fo=0)"
		fi
		return
	fi

	if echo "$fo" | grep -qEv "fo=[01ds:]+"; then
		print_bad "Failure reporting options set to unknown value (!= 0,1,d,s)"
		return
	fi

	fo_val=$(echo "$fo" | grep -oE "[01ds]")

	if echo "$fo_val" | grep -Eq "0"; then
		print_medium "Failure reporting options set to report only if all mechanisms fail (fo=0)"
	fi

	if echo "$fo_val" | grep -Eq '1'; then
		print_good "Failure reporting options set to 1 (fo=1)"
	fi

	# 'd' and 's' options may produce a lot of false positives. Leaving them out of the rule for now.
}

dmarc_id_alignment()
{
	local dmarc="$1"
	local id="$2"

	if [ "$dmarc" = "" ]; then
		return
	fi

	if echo "$dmarc" | grep -q "$id=s"; then
		print_good "DMARC Identifier alignment in strict mode (\"$id=s\")"
	else
		print_medium "DMARC Identifier alignment in relaxed mode (\"$id=r\")"
	fi
}

dkim_specific()
{
	local name="$1"
	local full_name="$2"
	local selectors="$3"

	if [ "$specific" != "$name" ]; then
		return
	fi

	for s in $selectors; do
		local curr
		curr=$(dig +short txt "$s._domainkey.$d" | grep 'v=DKIM')

		if [ "$curr" != "" ]; then
			print_good "DKIM $full_name set ($s)"
			dkim="$curr"
		fi
	done

	if [ "$dkim" = "" ]; then
		print_bad "DKIM $full_name selectors not set while $full_name is used"
	fi
}

dkim_well_known()
{
	log "Trying well-known selectors..."

	while read -r s; do
		print_info "$s"

		dkim=$(dig +short txt "$s._domainkey.$d" | grep "v=DKIM")

		if [ "$dkim" != "" ]; then
			print_good "DKIM found with selector $s: $dkim"
			return
		fi
	done < "$dkim_selectors_file"

	print_medium "DKIM could not be found, try obtaining a valid selector for manual review."
}

dkim_extract_key()
{
	if [ "$dkim_extract" -eq 0 ]; then
		return
	fi

	local dkim_p
	dkim_p=$(echo "$dkim" | grep -Eo 'p=[^;]+' | sed 's/p=//g' | sed 's/[ "]//g')

	print_info "Extracting DKIM public key..."

	echo "-----BEGIN PUBLIC KEY-----" > "$dkim_key_outfile"
	echo "$dkim_p" >> "$dkim_key_outfile"
	echo "-----END PUBLIC KEY-----" >> "$dkim_key_outfile"

	dkim_parsed_key=$(openssl rsa -pubin -in "$dkim_key_outfile" -text)

	log "$dkim_parsed_key"
}

dkim_crypto_keysize()
{
	if [ "$dkim_parsed_key" = "" ]; then
		return
	fi

	local keysize
	keysize=$(echo "$dkim_parsed_key" | grep -E 'Public-Key:[ ]+\([0-9]+[ ]+bit\)' | grep -Eo '[0-9]+')

	if [ "$keysize" -lt $dkim_key_minsize ]; then
		print_medium "DKIM public key size is < $dkim_key_minsize bits ($keysize bits)"
	else
		print_good "DKIM public key size is correct ($keysize bits)"
	fi
}

get_mta_sts()
{
	local domain="$1"

	mta_sts=$(dig +short txt "_mta-sts.$domain")
}

has_mta_sts()
{
	local mta_sts="$1"

	if [ "$mta_sts" = "" ]; then
		print_medium "MTA-STS record not defined"
	else
		print_good "MTA-STS record exists"
	fi
}

mta_sts_version()
{
	local mta_sts="$1"

	if [ "$mta_sts" = "" ]; then
		return
	fi

	if echo "$mta_sts" | grep -q "v=STSv1"; then
		print_good "MTA-STS version is correct"
	else
		print_bad "MTA-STS version incorrect"
	fi
}

mta_sts_policy()
{
	local domain="$1"

	if [ "$mta_sts" = "" ]; then
		return
	fi

	mta_sts_policy=$(curl -s "https://mta-sts.$domain/.well-known/mta-sts.txt")

	if [ "$mta_sts_policy" = "" ]; then
		print_bad "MTA-STS no policy available at https://mta-sts.$domain/.well-known/mta-sts.txt"
	else
		print_good "MTA-STS policy available:"
		log ""
		log "$mta_sts_policy"
		log ""
	fi
}

get_tls_rpt()
{
	local domain="$1"

	tls_rpt=$(dig +short txt "_smtp._tls.$domain")
}

has_tls_rpt()
{
	tls_rpt="$1"

	if [ "$tls_rpt" = "" ]; then
		print_medium "TLS-RPT record not defined"
	else
		print_good "TLS-RPT record found"
	fi
}

tls_rpt_version()
{
	tls_rpt="$1"

	if [ "$tls_rpt" = "" ]; then
		return
	fi

	if echo "$tls_rpt" | grep -q "v=TLSRPTv1"; then
		print_good "TLS-RPT version is correct"
	else
		print_bad "TLS-RPT version incorrect"
	fi
}

tls_rpt_third_party()
{
	domain="$1"
	tls_rpt="$2"

	if [ "$tls_rpt" = "" ]; then
		return
	fi

	rua=$(echo "$dmarc" | grep -oE "rua=[^ ]+";)

	if echo "$rua" | grep -Eo "@[^ ,\"]+" | grep -vq "$d"; then
		print_medium "TLS-RPT reports sent to third-parties, please review manually"
	fi
}

dane_tlsa_rec()
{
	domain="$1"
	port="$2"

	if [ "$domain" = "" ]; then
		return
	fi

	tlsa_rec=$(dig +short tlsa "_$port._tcp.$domain")

	if [ "$tlsa_rec" = "" ]; then
		print_bad "DANE no TLSA record for $domain"
		return
	fi

	print_good "DANE TLSA record for $domain: \"$tlsa_rec\""
}

dane()
{
	mx="$1"

	if [ "$mx" = "" ]; then
		return
	fi

	mx_names=$(echo "$mx" | awk '{print $2}')

	for entry in $mx_names; do
		# Only testing port 25 for now, might be necessary to add more
		dane_tlsa_rec "$entry" "25"
	done
}

get_bimi()
{
	local domain="$1"
	local selectors="$2"

	log "Trying well-known selectors..."

	while read -r s; do
		print_info "$s"
		local curr
		curr="$(dig +short txt "$s._bimi.$d" | grep 'v=BIMI')"

		if [ "$curr" != "" ]; then
			print_good "BIMI found for selector $s: $curr"
			bimi="$curr"
		fi
	done < "$bimi_selectors_file"
}

has_bimi()
{
	local bimi="$1"

	if [ "$bimi" = "" ]; then
		print_info "BIMI record not defined"
	else
		print_good "BIMI record exists"
	fi
}

bimi_version()
{
	local bimi="$1"

	if [ "$bimi" = "" ]; then
		return
	fi

	if echo "$bimi" | grep -q "v=BIMI1"; then
		print_good "BIMI version is correct"
	else
		print_bad "BIMI version incorrect"
	fi
}

if [ "$d" = "" ]; then
	echo "No domain provided."
	usage
	exit 1
fi

log "Checking \033[1;32m$d\033[0m"
log

# Preliminary checks
get_mx "$d"

if [ "$mx" = "" ]; then
	log "No MX record for domain, are you sure it is used for mail communications?"
else
	log "MX: $mx"
fi
log ""

has_mx_specific "m365" "Microsoft 365" "mail.protection.outlook.com"
has_mx_specific "google" "Google Workspace" "aspmx.l.google.com"
has_mx_specific "amazon" "Amazon SES" "amazonaws.com"
has_mx_specific "ovh" "OVH Cloud" "mail.ovh.net"
log ""

# SPF checks
get_spf "$d"

log "SPF: $spf"
log ""

has_spf "$spf"
spf_version "$spf"
loose_spf "$spf"
spf_include_domain "$spf" "m365" "Microsoft 365" "spf.protection.outlook.com" "$specific"
spf_include_domain "$spf" "google" "Google Workspace" "_spf.google.com" "$specific"
spf_include_domain "$spf" "amazon" "Amazon SES" "amazonses.com" "$specific"
spf_include_domain "$spf" "ovh" "OVH Cloud" "mx.ovh.com" "$specific"
spf_includes_recursive "$spf" "$d" "$specific"

# Only at the end of the recursion can we test if specific SPF has not been found
if [ "$specific" != "" ] && [ "$spf_specific_found" -eq 0 ]; then
	print_medium "$specific SPF not in includes ($spf_specific_include)"
fi

log ""

# DMARC checks
get_dmarc "$d"

log "DMARC: $dmarc"
log ""

has_dmarc "$dmarc"
dmarc_version "$dmarc"
loose_dmarc_policy "$dmarc"
loose_dmarc_subpolicy "$dmarc"
dmarc_pct "$dmarc"
dmarc_rua_ruf "$dmarc"
dmarc_fo "$dmarc"
dmarc_id_alignment "$dmarc" "aspf"
dmarc_id_alignment "$dmarc" "adkim"

log ""

# DKIM checks
log "DKIM:"
log ""

dkim_specific "m365" "Microsoft 365" "selector1 selector2"
dkim_specific "google" "Google Workspace" "google"

if [ "$specific" = "" ]; then
	dkim_well_known
fi

if [ "$dkim" != "" ]; then
	dkim_extract_key
	dkim_crypto_keysize
fi

log ""

# MTA-STS checks
get_mta_sts "$d"

log "MTA-STS: $mta_sts"
log ""

has_mta_sts "$mta_sts"
mta_sts_version "$mta_sts"
mta_sts_policy "$d"

log ""

# TLS-RPT checks
get_tls_rpt "$d"

log "TLS-RPT: $tls_rpt"
log ""

has_tls_rpt "$tls_rpt"
tls_rpt_version "$tls_rpt"
tls_rpt_third_party "$d" "$tls_rpt"

log ""

# DANE for SMTP
log "DANE for SMTP:"
log ""

dane "$mx"
log ""

# BIMI
log "BIMI:"
log ""

get_bimi "$d"
has_bimi "$bimi"
bimi_version "$bimi"
