- DNS: reverse DNS lookup on A record IPs to verify PTR configuration - Infra: ASN and organization lookup via whois.radb.net - Refactor checkARecords to return resolved IPs for downstream use Signed-off-by: Blake Ridgway <blake@blakeridgway.com>
184 lines
5.0 KiB
Go
184 lines
5.0 KiB
Go
package dns
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
|
|
"arcline-audit/internal/types"
|
|
)
|
|
|
|
// Run performs all DNS checks for the given domain.
|
|
func Run(domain string) types.DNSResult {
|
|
var checks []types.CheckResult
|
|
|
|
// A records (also resolves IPs for PTR check)
|
|
ips, aRecordChecks := checkARecords(domain)
|
|
checks = append(checks, aRecordChecks...)
|
|
|
|
// AAAA records
|
|
checks = append(checks, checkAAAARecords(domain)...)
|
|
|
|
// MX records
|
|
checks = append(checks, checkMXRecords(domain)...)
|
|
|
|
// TXT records (SPF, DKIM, DMARC)
|
|
checks = append(checks, checkTXTRecords(domain)...)
|
|
|
|
// DNSSEC
|
|
checks = append(checks, checkDNSSEC(domain)...)
|
|
|
|
// PTR / rDNS match
|
|
checks = append(checks, checkPTR(ips)...)
|
|
|
|
return types.DNSResult{Checks: checks}
|
|
}
|
|
|
|
// checkARecords returns the resolved IPv4 addresses and the check results.
|
|
func checkARecords(domain string) ([]string, []types.CheckResult) {
|
|
ips, err := net.LookupHost(domain)
|
|
if err != nil {
|
|
return nil, []types.CheckResult{{Status: types.StatusFail, Message: fmt.Sprintf("no A record: %v", err)}}
|
|
}
|
|
var ipv4 []string
|
|
for _, ip := range ips {
|
|
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() != nil {
|
|
ipv4 = append(ipv4, ip)
|
|
}
|
|
}
|
|
if len(ipv4) == 0 {
|
|
return nil, []types.CheckResult{{Status: types.StatusFail, Message: "no A record found"}}
|
|
}
|
|
return ipv4, []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("A record: %s", strings.Join(ipv4, ", "))}}
|
|
}
|
|
|
|
func checkAAAARecords(domain string) []types.CheckResult {
|
|
ips, err := net.LookupHost(domain)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var ipv6 []string
|
|
for _, ip := range ips {
|
|
if parsed := net.ParseIP(ip); parsed != nil && parsed.To4() == nil {
|
|
ipv6 = append(ipv6, ip)
|
|
}
|
|
}
|
|
if len(ipv6) == 0 {
|
|
return nil
|
|
}
|
|
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("AAAA record: %s", strings.Join(ipv6, ", "))}}
|
|
}
|
|
|
|
func checkMXRecords(domain string) []types.CheckResult {
|
|
mxs, err := net.LookupMX(domain)
|
|
if err != nil || len(mxs) == 0 {
|
|
return []types.CheckResult{{Status: types.StatusWarn, Message: "no MX records found"}}
|
|
}
|
|
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("MX records present (%d)", len(mxs))}}
|
|
}
|
|
|
|
func checkTXTRecords(domain string) []types.CheckResult {
|
|
txts, err := net.LookupTXT(domain)
|
|
if err != nil {
|
|
return []types.CheckResult{
|
|
{Status: types.StatusWarn, Message: "no TXT records found"},
|
|
}
|
|
}
|
|
|
|
var checks []types.CheckResult
|
|
hasSPF := false
|
|
|
|
for _, txt := range txts {
|
|
if strings.HasPrefix(txt, "v=spf1") {
|
|
hasSPF = true
|
|
}
|
|
}
|
|
|
|
if hasSPF {
|
|
checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "SPF record found"})
|
|
} else {
|
|
checks = append(checks, types.CheckResult{Status: types.StatusWarn, Message: "no SPF record"})
|
|
}
|
|
|
|
// DKIM is checked via selector lookup
|
|
dkimFound := checkDKIM(domain)
|
|
if dkimFound {
|
|
checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "DKIM record found (default._domainkey)"})
|
|
} else {
|
|
checks = append(checks, types.CheckResult{Status: types.StatusInfo, Message: "no DKIM record (default._domainkey)"})
|
|
}
|
|
|
|
// DMARC is on _dmarc subdomain
|
|
dmarcTxts, err := net.LookupTXT("_dmarc." + domain)
|
|
if err == nil {
|
|
for _, txt := range dmarcTxts {
|
|
if strings.HasPrefix(txt, "v=DMARC1") {
|
|
checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "DMARC record found"})
|
|
return checks
|
|
}
|
|
}
|
|
}
|
|
checks = append(checks, types.CheckResult{Status: types.StatusWarn, Message: "no DMARC record"})
|
|
|
|
return checks
|
|
}
|
|
|
|
func checkDKIM(domain string) bool {
|
|
txts, err := net.LookupTXT("default._domainkey." + domain)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
for _, txt := range txts {
|
|
if strings.Contains(txt, "v=DKIM1") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func checkDNSSEC(domain string) []types.CheckResult {
|
|
_, err := net.LookupTXT("_dnssec." + domain)
|
|
if err == nil {
|
|
return []types.CheckResult{{Status: types.StatusOK, Message: "DNSSEC appears enabled"}}
|
|
}
|
|
return []types.CheckResult{{Status: types.StatusInfo, Message: "DNSSEC check requires custom resolver (not verified)"}}
|
|
}
|
|
|
|
// checkPTR performs a reverse DNS lookup on the given IPs and checks if
|
|
// any returned hostname resolves back to one of the original IPs.
|
|
func checkPTR(ips []string) []types.CheckResult {
|
|
if len(ips) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Check up to the first 2 IPs to keep things fast.
|
|
limit := 2
|
|
if len(ips) < limit {
|
|
limit = len(ips)
|
|
}
|
|
|
|
for i := 0; i < limit; i++ {
|
|
ip := ips[i]
|
|
names, err := net.LookupAddr(ip)
|
|
if err != nil || len(names) == 0 {
|
|
continue
|
|
}
|
|
|
|
for _, name := range names {
|
|
name = strings.TrimSuffix(name, ".")
|
|
resolved, err := net.LookupHost(name)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, resolvedIP := range resolved {
|
|
if resolvedIP == ip {
|
|
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("rDNS matches (%s → %s)", ip, name)}}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return []types.CheckResult{{Status: types.StatusInfo, Message: "no PTR record found (rDNS not configured)"}}
|
|
}
|
|
|