add PTR/rDNS match check and ASN/org lookup via WHOIS

- 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>
This commit is contained in:
Blake Ridgway
2026-06-23 05:12:22 -05:00
parent fce90f458c
commit 825df331a4
3 changed files with 103 additions and 24 deletions

Binary file not shown.

View File

@@ -12,8 +12,9 @@ import (
func Run(domain string) types.DNSResult { func Run(domain string) types.DNSResult {
var checks []types.CheckResult var checks []types.CheckResult
// A records // A records (also resolves IPs for PTR check)
checks = append(checks, checkARecords(domain)...) ips, aRecordChecks := checkARecords(domain)
checks = append(checks, aRecordChecks...)
// AAAA records // AAAA records
checks = append(checks, checkAAAARecords(domain)...) checks = append(checks, checkAAAARecords(domain)...)
@@ -27,13 +28,17 @@ func Run(domain string) types.DNSResult {
// DNSSEC // DNSSEC
checks = append(checks, checkDNSSEC(domain)...) checks = append(checks, checkDNSSEC(domain)...)
// PTR / rDNS match
checks = append(checks, checkPTR(ips)...)
return types.DNSResult{Checks: checks} return types.DNSResult{Checks: checks}
} }
func checkARecords(domain string) []types.CheckResult { // checkARecords returns the resolved IPv4 addresses and the check results.
func checkARecords(domain string) ([]string, []types.CheckResult) {
ips, err := net.LookupHost(domain) ips, err := net.LookupHost(domain)
if err != nil { if err != nil {
return []types.CheckResult{{Status: types.StatusFail, Message: fmt.Sprintf("no A record: %v", err)}} return nil, []types.CheckResult{{Status: types.StatusFail, Message: fmt.Sprintf("no A record: %v", err)}}
} }
var ipv4 []string var ipv4 []string
for _, ip := range ips { for _, ip := range ips {
@@ -42,9 +47,9 @@ func checkARecords(domain string) []types.CheckResult {
} }
} }
if len(ipv4) == 0 { if len(ipv4) == 0 {
return []types.CheckResult{{Status: types.StatusFail, Message: "no A record found"}} return nil, []types.CheckResult{{Status: types.StatusFail, Message: "no A record found"}}
} }
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("A record: %s", strings.Join(ipv4, ", "))}} return ipv4, []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("A record: %s", strings.Join(ipv4, ", "))}}
} }
func checkAAAARecords(domain string) []types.CheckResult { func checkAAAARecords(domain string) []types.CheckResult {
@@ -59,7 +64,7 @@ func checkAAAARecords(domain string) []types.CheckResult {
} }
} }
if len(ipv6) == 0 { if len(ipv6) == 0 {
return nil // Not a warning; many sites don't have IPv6 return nil
} }
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("AAAA record: %s", strings.Join(ipv6, ", "))}} return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("AAAA record: %s", strings.Join(ipv6, ", "))}}
} }
@@ -82,7 +87,6 @@ func checkTXTRecords(domain string) []types.CheckResult {
var checks []types.CheckResult var checks []types.CheckResult
hasSPF := false hasSPF := false
hasDMARC := false
for _, txt := range txts { for _, txt := range txts {
if strings.HasPrefix(txt, "v=spf1") { if strings.HasPrefix(txt, "v=spf1") {
@@ -109,16 +113,12 @@ func checkTXTRecords(domain string) []types.CheckResult {
if err == nil { if err == nil {
for _, txt := range dmarcTxts { for _, txt := range dmarcTxts {
if strings.HasPrefix(txt, "v=DMARC1") { if strings.HasPrefix(txt, "v=DMARC1") {
hasDMARC = true
break
}
}
}
if hasDMARC {
checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "DMARC record found"}) checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "DMARC record found"})
} else { return checks
checks = append(checks, types.CheckResult{Status: types.StatusWarn, Message: "no DMARC record"})
} }
}
}
checks = append(checks, types.CheckResult{Status: types.StatusWarn, Message: "no DMARC record"})
return checks return checks
} }
@@ -137,19 +137,47 @@ func checkDKIM(domain string) bool {
} }
func checkDNSSEC(domain string) []types.CheckResult { func checkDNSSEC(domain string) []types.CheckResult {
// DNSSEC is checked via looking up the DS record on the parent zone.
// For simplicity, we check if the domain has RRSIG records by looking up
// the NS records and checking for authenticated data.
// A true DNSSEC check requires a validating resolver. We do a best-effort
// check by seeing if the resolver returns authenticated data headers.
// As a simple heuristic, we check for DNSKEY records.
_, err := net.LookupTXT("_dnssec." + domain) _, err := net.LookupTXT("_dnssec." + domain)
if err == nil { if err == nil {
return []types.CheckResult{{Status: types.StatusOK, Message: "DNSSEC appears enabled"}} return []types.CheckResult{{Status: types.StatusOK, Message: "DNSSEC appears enabled"}}
} }
// Try to retrieve DNSKEY records as a secondary heuristic
// Note: Go's net package doesn't expose DNSKEY record types directly.
// A full DNSSEC check would require a custom DNS resolver.
return []types.CheckResult{{Status: types.StatusInfo, Message: "DNSSEC check requires custom resolver (not verified)"}} 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)"}}
}

View File

@@ -1,6 +1,7 @@
package infra package infra
import ( import (
"bufio"
"fmt" "fmt"
"net" "net"
"strings" "strings"
@@ -72,6 +73,20 @@ func Run(domain string, resolvedIPs []string) types.InfraResult {
} }
result.CDN = cdn result.CDN = cdn
// ASN / org lookup via WHOIS
asn, org := lookupASN(targetIP)
if asn != "" {
result.ASN = asn
result.Org = org
msg := fmt.Sprintf("ASN: %s", asn)
if org != "" {
msg += fmt.Sprintf(" (%s)", org)
}
result.Checks = append(result.Checks, types.CheckResult{
Status: types.StatusInfo, Message: msg,
})
}
// Common ports check // Common ports check
result.Checks = append(result.Checks, checkPorts(targetIP)...) result.Checks = append(result.Checks, checkPorts(targetIP)...)
@@ -107,6 +122,42 @@ func detectCDN(ip string) string {
return "" return ""
} }
// lookupASN queries whois.radb.net for the ASN and organization of an IP.
func lookupASN(ip string) (asn, org string) {
conn, err := net.DialTimeout("tcp", "whois.radb.net:43", 5*time.Second)
if err != nil {
return "", ""
}
defer conn.Close()
// Set a deadline for the entire WHOIS exchange.
conn.SetDeadline(time.Now().Add(10 * time.Second))
fmt.Fprintf(conn, "%s\r\n", ip)
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := scanner.Text()
if asn == "" && strings.HasPrefix(line, "origin:") {
asn = strings.TrimSpace(strings.TrimPrefix(line, "origin:"))
asn = strings.ToUpper(asn)
}
if org == "" && strings.HasPrefix(line, "descr:") {
org = strings.TrimSpace(strings.TrimPrefix(line, "descr:"))
}
if asn != "" && org != "" {
break
}
}
// Trim long descriptions to keep output readable.
if len(org) > 80 {
org = org[:77] + "..."
}
return asn, org
}
func checkPorts(ip string) []types.CheckResult { func checkPorts(ip string) []types.CheckResult {
var checks []types.CheckResult var checks []types.CheckResult
var mu sync.Mutex var mu sync.Mutex