diff --git a/arcline-audit b/arcline-audit deleted file mode 100755 index bd75c22..0000000 Binary files a/arcline-audit and /dev/null differ diff --git a/internal/dns/checker.go b/internal/dns/checker.go index d0fe1c2..3faadd6 100644 --- a/internal/dns/checker.go +++ b/internal/dns/checker.go @@ -12,8 +12,9 @@ import ( func Run(domain string) types.DNSResult { var checks []types.CheckResult - // A records - checks = append(checks, checkARecords(domain)...) + // A records (also resolves IPs for PTR check) + ips, aRecordChecks := checkARecords(domain) + checks = append(checks, aRecordChecks...) // AAAA records checks = append(checks, checkAAAARecords(domain)...) @@ -27,13 +28,17 @@ func Run(domain string) types.DNSResult { // DNSSEC checks = append(checks, checkDNSSEC(domain)...) + // PTR / rDNS match + checks = append(checks, checkPTR(ips)...) + 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) 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 for _, ip := range ips { @@ -42,9 +47,9 @@ func checkARecords(domain string) []types.CheckResult { } } 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 { @@ -59,7 +64,7 @@ func checkAAAARecords(domain string) []types.CheckResult { } } 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, ", "))}} } @@ -82,7 +87,6 @@ func checkTXTRecords(domain string) []types.CheckResult { var checks []types.CheckResult hasSPF := false - hasDMARC := false for _, txt := range txts { if strings.HasPrefix(txt, "v=spf1") { @@ -109,16 +113,12 @@ func checkTXTRecords(domain string) []types.CheckResult { if err == nil { for _, txt := range dmarcTxts { if strings.HasPrefix(txt, "v=DMARC1") { - hasDMARC = true - break + checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "DMARC record found"}) + return checks } } } - if hasDMARC { - checks = append(checks, types.CheckResult{Status: types.StatusOK, Message: "DMARC record found"}) - } else { - 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 } @@ -137,19 +137,47 @@ func checkDKIM(domain string) bool { } 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) if err == nil { 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)"}} } +// 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)"}} +} + diff --git a/internal/infra/checker.go b/internal/infra/checker.go index 4452a06..6065594 100644 --- a/internal/infra/checker.go +++ b/internal/infra/checker.go @@ -1,6 +1,7 @@ package infra import ( + "bufio" "fmt" "net" "strings" @@ -72,6 +73,20 @@ func Run(domain string, resolvedIPs []string) types.InfraResult { } 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 result.Checks = append(result.Checks, checkPorts(targetIP)...) @@ -107,6 +122,42 @@ func detectCDN(ip string) string { 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 { var checks []types.CheckResult var mu sync.Mutex