CI / test (pull_request) Failing after 2m31s
Three bugs fixed: 1. processReferral was calling ResolveNS with ref.Name (the query domain, e.g. '800adventures.com.au.') instead of ref.Bailiwick (the NS hostname, e.g. 'ns-a.hansenits.com.'). This caused the sub-traversal to look up the wrong name and always fail to find the nameserver's IP address. 2. In ResolveNS (and Referral.Resolve), child referrals whose name matched the visited set were unconditionally skipped. When the .com TLD returns glue A records for the target NS alongside its delegation, the child referral has addresses and should be queried directly rather than skipped. 3. FormatRecord was prepending the DNS header fields and then appending rr.String() which already includes those same fields, producing doubled output like 'example.com. 300 IN A example.com. 300 IN A 1.2.3.4'. Now simply returns rr.String(). Additional improvements: - Results section deduplicates terminal results: same NS failure or same (NS, answer) pair is merged with summed probability, avoiding the same nameserver appearing 15 times with 6.7% each. - Result lines now include the NS hostname (from Bailiwick) and use the compact rdata format, e.g. '33% ns-a.hansenits.com answered with 13.54.63.231'. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: multica-agent <github@multica.ai>
250 lines
5.7 KiB
Go
250 lines
5.7 KiB
Go
package output
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/hits/ExploreDNS/internal/dns"
|
|
"github.com/hits/ExploreDNS/internal/traverse"
|
|
miekgdns "github.com/miekg/dns"
|
|
)
|
|
|
|
type summaryEntry struct {
|
|
Type string
|
|
Prob float64
|
|
}
|
|
|
|
type answerEntry struct {
|
|
RData string
|
|
Prob float64
|
|
RRs []string
|
|
}
|
|
|
|
type SummaryStats struct {
|
|
ByType map[string]float64
|
|
Answers []answerEntry
|
|
}
|
|
|
|
func ComputeSummary(results []traverse.TraversalResult) *SummaryStats {
|
|
stats := &SummaryStats{
|
|
ByType: make(map[string]float64),
|
|
}
|
|
|
|
for _, result := range results {
|
|
if result.Response == nil || !result.Response.IsTerminal() {
|
|
continue
|
|
}
|
|
if result.Referral == nil {
|
|
continue
|
|
}
|
|
|
|
prob := result.Referral.Prob
|
|
respType := result.Response.Type.String()
|
|
|
|
switch result.Response.Type {
|
|
case traverse.RespAnswer:
|
|
key, rrs := answerKey(result.Response)
|
|
if key == "" {
|
|
stats.ByType[respType] += prob
|
|
continue
|
|
}
|
|
found := false
|
|
for i := range stats.Answers {
|
|
if stats.Answers[i].RData == key {
|
|
stats.Answers[i].Prob += prob
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
stats.Answers = append(stats.Answers, answerEntry{
|
|
RData: key,
|
|
Prob: prob,
|
|
RRs: rrs,
|
|
})
|
|
}
|
|
default:
|
|
stats.ByType[respType] += prob
|
|
}
|
|
}
|
|
|
|
sort.Slice(stats.Answers, func(i, j int) bool {
|
|
return stats.Answers[i].RData < stats.Answers[j].RData
|
|
})
|
|
|
|
if len(stats.Answers) == 0 && len(stats.ByType) == 0 {
|
|
return nil
|
|
}
|
|
return stats
|
|
}
|
|
|
|
func answerKey(resp *traverse.Response) (string, []string) {
|
|
if resp == nil || resp.Decoded == nil {
|
|
return "", nil
|
|
}
|
|
|
|
var rdatas []string
|
|
var formatted []string
|
|
for _, rr := range resp.Decoded.Answers {
|
|
if _, ok := rr.(*miekgdns.CNAME); ok {
|
|
continue
|
|
}
|
|
rdata := rrDataString(rr)
|
|
if rdata == "" {
|
|
continue
|
|
}
|
|
rdatas = append(rdatas, rdata)
|
|
formatted = append(formatted, dns.FormatRecord(rr))
|
|
}
|
|
|
|
if len(rdatas) == 0 {
|
|
return "", nil
|
|
}
|
|
sort.Strings(rdatas)
|
|
return strings.Join(rdatas, " / "), formatted
|
|
}
|
|
|
|
func rrDataString(rr miekgdns.RR) string {
|
|
switch v := rr.(type) {
|
|
case *miekgdns.A:
|
|
return v.A.String()
|
|
case *miekgdns.AAAA:
|
|
return v.AAAA.String()
|
|
case *miekgdns.CNAME:
|
|
return v.Target
|
|
case *miekgdns.NS:
|
|
return v.Ns
|
|
case *miekgdns.MX:
|
|
return fmt.Sprintf("%d %s", v.Preference, v.Mx)
|
|
case *miekgdns.TXT:
|
|
return strings.Join(v.Txt, " ")
|
|
default:
|
|
return rr.String()
|
|
}
|
|
}
|
|
|
|
func formatProbability(prob float64) string {
|
|
text := fmt.Sprintf("%.1f%%", prob*100)
|
|
text = strings.Replace(text, ".0%", "%", 1)
|
|
return fmt.Sprintf("%5s", text)
|
|
}
|
|
|
|
func summaryTypeLabel(respType string) string {
|
|
switch respType {
|
|
case "nodata":
|
|
return "found no such record"
|
|
case "nxdomain":
|
|
return "name does not exist"
|
|
case "servfail":
|
|
return "resulted in SERVFAIL"
|
|
case "refused":
|
|
return "query refused by server"
|
|
case "notimp":
|
|
return "query type not implemented by server"
|
|
case "cname_loop":
|
|
return "resulted in a CNAME loop"
|
|
case "ns_error":
|
|
return "nameserver lookup failed"
|
|
case "error":
|
|
return "resulted in an error"
|
|
case "referral":
|
|
return "resulted in a referral"
|
|
default:
|
|
return respType
|
|
}
|
|
}
|
|
|
|
func trimDomain(name string) string {
|
|
return strings.TrimSuffix(name, ".")
|
|
}
|
|
|
|
func collectServers(results []traverse.TraversalResult) map[string][]string {
|
|
servers := make(map[string][]string)
|
|
for _, result := range results {
|
|
if result.Response == nil || result.Response.Server == nil {
|
|
continue
|
|
}
|
|
name := serverName(result)
|
|
ip := result.Response.Server.String()
|
|
if containsString(servers[name], ip) {
|
|
continue
|
|
}
|
|
servers[name] = append(servers[name], ip)
|
|
}
|
|
return servers
|
|
}
|
|
|
|
func serverName(result traverse.TraversalResult) string {
|
|
if result.Referral != nil && result.Referral.Bailiwick != "" && result.Referral.Bailiwick != "." {
|
|
return trimDomain(result.Referral.Bailiwick)
|
|
}
|
|
if result.Referral != nil && result.Referral.NSName != "" {
|
|
return result.Referral.NSName
|
|
}
|
|
if result.Response != nil && result.Response.Server != nil {
|
|
return result.Response.Server.String()
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func containsString(items []string, target string) bool {
|
|
for _, item := range items {
|
|
if item == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// DeduplicateResults collapses terminal results that represent the same
|
|
// outcome from the same server into a single entry with summed probability.
|
|
// This prevents the same nameserver failure (or answer) from appearing once
|
|
// per delegation path when several parent servers all refer to the same child.
|
|
func DeduplicateResults(results []traverse.TraversalResult) []traverse.TraversalResult {
|
|
type entry struct {
|
|
result traverse.TraversalResult
|
|
prob float64
|
|
}
|
|
keys := make(map[string]*entry)
|
|
var order []string
|
|
|
|
for _, r := range results {
|
|
if r.Response == nil || r.Referral == nil {
|
|
continue
|
|
}
|
|
key := resultDeduplicationKey(r)
|
|
if e, ok := keys[key]; ok {
|
|
e.prob += r.Referral.Prob
|
|
} else {
|
|
keys[key] = &entry{result: r, prob: r.Referral.Prob}
|
|
order = append(order, key)
|
|
}
|
|
}
|
|
|
|
deduped := make([]traverse.TraversalResult, 0, len(order))
|
|
for _, key := range order {
|
|
e := keys[key]
|
|
refCopy := *e.result.Referral
|
|
refCopy.Prob = e.prob
|
|
deduped = append(deduped, traverse.TraversalResult{
|
|
Referral: &refCopy,
|
|
Response: e.result.Response,
|
|
})
|
|
}
|
|
return deduped
|
|
}
|
|
|
|
func resultDeduplicationKey(r traverse.TraversalResult) string {
|
|
bailiwick := strings.TrimSuffix(r.Referral.Bailiwick, ".")
|
|
switch r.Response.Type {
|
|
case traverse.RespAnswer:
|
|
key, _ := answerKey(r.Response)
|
|
return "answer:" + bailiwick + ":" + key
|
|
case traverse.RespNSResolutionFailed:
|
|
return "ns_error:" + r.Response.ErrorMessage
|
|
default:
|
|
return r.Response.Type.String() + ":" + bailiwick + ":" + r.Response.ErrorMessage
|
|
}
|
|
}
|