From c0e86317b7ba0099478c67762879388055add1ef Mon Sep 17 00:00:00 2001 From: Steffen Fritz Date: Sat, 28 Mar 2026 23:43:21 +0100 Subject: [PATCH] Add TLSRPT, BIMI, full DMARC parsing and TLS certificate details - dns: parse all DMARC fields (p, sp, adkim, aspf, fo, rua, ruf, pct, ri, rf) instead of storing only the raw record string - dns: add getTLSRPT() to query _smtp._tls. TXT records (RFC 8460) - dns: add getBIMI() to query default._bimi. TXT records - tlscheck: introduce tlscertinfo struct; tlsCheck() now returns expiry date, subject CN, issuer CN and SANs in addition to TLS version and validity flags - main: display parsed DMARC fields, TLSRPT RUA, BIMI logo/authority URIs, and certificate details (expiry, subject, issuer, SANs) for port 465 - output: expand TSV report with individual DMARC fields, TLSRPT, BIMI, and per-MX TLS certificate details Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Steffen Fritz --- bimi.go | 9 ++++ dns.go | 123 ++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 80 ++++++++++++++++++++++++++++++++-- output.go | 77 +++++++++++++++++++++++++++++++- tlscheck.go | 39 ++++++++++++----- tlsrpt.go | 8 ++++ 6 files changed, 320 insertions(+), 16 deletions(-) create mode 100644 bimi.go create mode 100644 tlsrpt.go diff --git a/bimi.go b/bimi.go new file mode 100644 index 0000000..54c8291 --- /dev/null +++ b/bimi.go @@ -0,0 +1,9 @@ +package main + +type bimi struct { + bimiset bool + bimifull string + v string + l string // location: SVG logo URI + a string // authority: VMC URI (optional) +} diff --git a/dns.go b/dns.go index 40c076e..3872bf4 100644 --- a/dns.go +++ b/dns.go @@ -251,6 +251,37 @@ func getDMARC(targetHostName string, dnsServer string) (dmarc, error) { if strings.Contains(v, "v=DMARC1") { dmarc.dmarcset = true dmarc.dmarcfull = in.Answer[n].String() + for _, part := range strings.Split(v, ";") { + part = strings.TrimSpace(part) + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + switch strings.TrimSpace(kv[0]) { + case "v": + dmarc.v = strings.TrimSpace(kv[1]) + case "p": + dmarc.p = strings.TrimSpace(kv[1]) + case "sp": + dmarc.sp = strings.TrimSpace(kv[1]) + case "adkim": + dmarc.adkim = strings.TrimSpace(kv[1]) + case "aspf": + dmarc.aspf = strings.TrimSpace(kv[1]) + case "fo": + dmarc.fo = strings.TrimSpace(kv[1]) + case "rua": + dmarc.rua = strings.TrimSpace(kv[1]) + case "ruf": + dmarc.ruf = strings.TrimSpace(kv[1]) + case "rf": + dmarc.rf = strings.TrimSpace(kv[1]) + case "ri": + dmarc.ri = strings.TrimSpace(kv[1]) + case "pct": + dmarc.pct = strings.TrimSpace(kv[1]) + } + } } } } @@ -259,3 +290,95 @@ func getDMARC(targetHostName string, dnsServer string) (dmarc, error) { return dmarc, nil } + +// getTLSRPT builds a TXT request for _smtp._tls. (RFC 8460) +// and sends it to a dns server. +// It returns type tlsrpt and an error. +func getTLSRPT(targetHostName string, dnsServer string) (tlsrpt, error) { + var result tlsrpt + + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn("_smtp._tls."+targetHostName), dns.TypeTXT) + + c := new(dns.Client) + c.Net = "tcp" + in, _, err := c.Exchange(m, dnsServer+":53") + if err != nil { + return result, err + } + + if len(in.Answer) != 0 { + for n := range in.Answer { + if t, ok := in.Answer[n].(*dns.TXT); ok { + for _, v := range t.Txt { + if strings.HasPrefix(v, "v=TLSRPTv1") { + result.tlsrptset = true + result.tlsrptfull = in.Answer[n].String() + for _, part := range strings.Split(v, ";") { + part = strings.TrimSpace(part) + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + switch strings.TrimSpace(kv[0]) { + case "v": + result.v = strings.TrimSpace(kv[1]) + case "rua": + result.rua = strings.TrimSpace(kv[1]) + } + } + } + } + } + } + } + + return result, nil +} + +// getBIMI builds a TXT request for default._bimi. +// and sends it to a dns server. +// It returns type bimi and an error. +func getBIMI(targetHostName string, dnsServer string) (bimi, error) { + var result bimi + + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn("default._bimi."+targetHostName), dns.TypeTXT) + + c := new(dns.Client) + c.Net = "tcp" + in, _, err := c.Exchange(m, dnsServer+":53") + if err != nil { + return result, err + } + + if len(in.Answer) != 0 { + for n := range in.Answer { + if t, ok := in.Answer[n].(*dns.TXT); ok { + for _, v := range t.Txt { + if strings.HasPrefix(v, "v=BIMI1") { + result.bimiset = true + result.bimifull = in.Answer[n].String() + for _, part := range strings.Split(v, ";") { + part = strings.TrimSpace(part) + kv := strings.SplitN(part, "=", 2) + if len(kv) != 2 { + continue + } + switch strings.TrimSpace(kv[0]) { + case "v": + result.v = strings.TrimSpace(kv[1]) + case "l": + result.l = strings.TrimSpace(kv[1]) + case "a": + result.a = strings.TrimSpace(kv[1]) + } + } + } + } + } + } + } + + return result, nil +} diff --git a/main.go b/main.go index 59e254c..9518daa 100644 --- a/main.go +++ b/main.go @@ -30,6 +30,9 @@ type runresult struct { bldnsipnotlisted map[string]string dmarcset bool dmarcfull string + dmarcresult dmarc + tlsrptresult tlsrpt + bimiresult bimi } // mxresult is used to store a mx scan result for further processing @@ -50,6 +53,10 @@ type mxresult struct { starttlsversion string tlscertvalid bool tlsversion string + tlscertexpiry time.Time + tlscertsubjectcn string + tlscertissuercn string + tlscertsans []string smtps bool openrelay bool vrfysupport bool @@ -181,15 +188,67 @@ func main() { } if dmarcentry.dmarcset { runresult.dmarcset = true - // this copying makes little sense tbh right now. But if we parse the answer it will help. runresult.dmarcfull = dmarcentry.dmarcfull + runresult.dmarcresult = dmarcentry InfoLogger.Println(Green("DMARC set")) - InfoLogger.Println(runresult.dmarcfull) + InfoLogger.Println("DMARC Policy: " + dmarcentry.p) + if len(dmarcentry.sp) > 0 { + InfoLogger.Println("DMARC Subdomain Policy: " + dmarcentry.sp) + } + if len(dmarcentry.adkim) > 0 { + InfoLogger.Println("DMARC DKIM Alignment: " + dmarcentry.adkim) + } + if len(dmarcentry.aspf) > 0 { + InfoLogger.Println("DMARC SPF Alignment: " + dmarcentry.aspf) + } + if len(dmarcentry.fo) > 0 { + InfoLogger.Println("DMARC Failure Options: " + dmarcentry.fo) + } + if len(dmarcentry.rua) > 0 { + InfoLogger.Println("DMARC RUA: " + dmarcentry.rua) + } + if len(dmarcentry.ruf) > 0 { + InfoLogger.Println("DMARC RUF: " + dmarcentry.ruf) + } + if len(dmarcentry.pct) > 0 { + InfoLogger.Println("DMARC Pct: " + dmarcentry.pct) + } } else { // This is just yellow because DMARC has its friends and foes... InfoLogger.Println(Yellow("No DMARC set")) } + // TLSRPT lookup + InfoLogger.Println("== Checking TLSRPT record ==") + tlsrptentry, err := getTLSRPT(*targetHostName, *dnsServer) + if err != nil { + ErrorLogger.Println(err.Error()) + } + runresult.tlsrptresult = tlsrptentry + if tlsrptentry.tlsrptset { + InfoLogger.Println(Green("TLSRPT set")) + InfoLogger.Println("TLSRPT RUA: " + tlsrptentry.rua) + } else { + InfoLogger.Println(Yellow("No TLSRPT set")) + } + + // BIMI lookup + InfoLogger.Println("== Checking BIMI record ==") + bimientry, err := getBIMI(*targetHostName, *dnsServer) + if err != nil { + ErrorLogger.Println(err.Error()) + } + runresult.bimiresult = bimientry + if bimientry.bimiset { + InfoLogger.Println(Green("BIMI set")) + InfoLogger.Println("BIMI Logo URI: " + bimientry.l) + if len(bimientry.a) > 0 { + InfoLogger.Println("BIMI Authority URI: " + bimientry.a) + } + } else { + InfoLogger.Println(Yellow("No BIMI set")) + } + // Check blacklists for domain name // InfoLogger.Println("Checking if domain is blacklisted") if *blacklist { @@ -400,11 +459,18 @@ func main() { // TLS test if port == "465" { InfoLogger.Println("== Checking for TLS support on port " + port + " ==") - orresult.tlsversion, orresult.tlsbool, orresult.tlsvalid, err = tlsCheck(targetHost, port) + certinfo, err := tlsCheck(targetHost, port) if err != nil { InfoLogger.Println(err) } - singlemx.tlsversion = orresult.tlsversion + orresult.tlsbool = certinfo.tlsok + orresult.tlsvalid = certinfo.certvalid + orresult.tlsversion = certinfo.version + singlemx.tlsversion = certinfo.version + singlemx.tlscertexpiry = certinfo.expiry + singlemx.tlscertsubjectcn = certinfo.subjectCN + singlemx.tlscertissuercn = certinfo.issuerCN + singlemx.tlscertsans = certinfo.sans if orresult.tlsbool { InfoLogger.Println(Green("SMTPS supported")) if orresult.tlsvalid { @@ -419,6 +485,12 @@ func main() { } else { InfoLogger.Println("SMTPS TLS Version: " + orresult.tlsversion) } + if !certinfo.expiry.IsZero() { + InfoLogger.Println("SMTPS Cert Expiry: " + certinfo.expiry.Format(time.RFC3339)) + InfoLogger.Println("SMTPS Cert Subject CN: " + certinfo.subjectCN) + InfoLogger.Println("SMTPS Cert Issuer CN: " + certinfo.issuerCN) + InfoLogger.Println("SMTPS Cert SANs: " + strings.Join(certinfo.sans, ", ")) + } } else { InfoLogger.Println(Cyan("SMTPS not supported")) } diff --git a/output.go b/output.go index 7b9e36a..33efea8 100644 --- a/output.go +++ b/output.go @@ -6,6 +6,7 @@ import ( "os" "slices" "strconv" + "strings" "time" ) @@ -74,7 +75,65 @@ func writeTSV(targetHostName string, runresult runresult, blacklist bool) error return err } if runresult.dmarcset { - if err = tsv.Write([]string{"DMARC Entry", runresult.dmarcfull}); err != nil { + d := runresult.dmarcresult + if err = tsv.Write([]string{"DMARC Full Entry", d.dmarcfull}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC Version", d.v}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC Policy", d.p}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC Subdomain Policy", d.sp}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC DKIM Alignment", d.adkim}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC SPF Alignment", d.aspf}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC Failure Options", d.fo}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC RUA", d.rua}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC RUF", d.ruf}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC Pct", d.pct}); err != nil { + return err + } + if err = tsv.Write([]string{"DMARC RI", d.ri}); err != nil { + return err + } + } + + if err = tsv.Write([]string{"TLSRPT Set", strconv.FormatBool(runresult.tlsrptresult.tlsrptset)}); err != nil { + return err + } + if runresult.tlsrptresult.tlsrptset { + if err = tsv.Write([]string{"TLSRPT RUA", runresult.tlsrptresult.rua}); err != nil { + return err + } + if err = tsv.Write([]string{"TLSRPT Full Entry", runresult.tlsrptresult.tlsrptfull}); err != nil { + return err + } + } + + if err = tsv.Write([]string{"BIMI Set", strconv.FormatBool(runresult.bimiresult.bimiset)}); err != nil { + return err + } + if runresult.bimiresult.bimiset { + if err = tsv.Write([]string{"BIMI Logo URI", runresult.bimiresult.l}); err != nil { + return err + } + if err = tsv.Write([]string{"BIMI Authority URI", runresult.bimiresult.a}); err != nil { + return err + } + if err = tsv.Write([]string{"BIMI Full Entry", runresult.bimiresult.bimifull}); err != nil { return err } } @@ -127,9 +186,23 @@ func writeTSV(targetHostName string, runresult runresult, blacklist bool) error return err } if slices.Contains(mxentry.openports, "465") { - if err = tsv.Write([]string{"TLS Version PORT 465 ", mxentry.tlsversion}); err != nil { + if err = tsv.Write([]string{"TLS Version PORT 465", mxentry.tlsversion}); err != nil { return err } + if !mxentry.tlscertexpiry.IsZero() { + if err = tsv.Write([]string{"TLS Cert Expiry", mxentry.tlscertexpiry.Format(time.RFC3339)}); err != nil { + return err + } + if err = tsv.Write([]string{"TLS Cert Subject CN", mxentry.tlscertsubjectcn}); err != nil { + return err + } + if err = tsv.Write([]string{"TLS Cert Issuer CN", mxentry.tlscertissuercn}); err != nil { + return err + } + if err = tsv.Write([]string{"TLS Cert SANs", strings.Join(mxentry.tlscertsans, ",")}); err != nil { + return err + } + } } if err = tsv.Write([]string{"VRFY Supported", strconv.FormatBool(mxentry.vrfysupport)}); err != nil { return err diff --git a/tlscheck.go b/tlscheck.go index 53c7eb3..0ae017b 100644 --- a/tlscheck.go +++ b/tlscheck.go @@ -3,9 +3,11 @@ package main import ( "crypto/tls" "errors" + "strings" + "time" + . "github.com/logrusorgru/aurora" "net" - "strings" ) var tlsversions = map[uint16]string{ @@ -16,13 +18,23 @@ var tlsversions = map[uint16]string{ tls.VersionTLS13: "TLS 1.3", } -func tlsCheck(targetHost string, port string) (string, bool, bool, error) { - tlsbool := false - tlsvalid := false +// tlscertinfo holds TLS connection and certificate details. +type tlscertinfo struct { + version string // negotiated TLS version + tlsok bool // TLS handshake succeeded + certvalid bool // certificate passed host validation + expiry time.Time // NotAfter from leaf certificate + subjectCN string // Subject Common Name of leaf certificate + issuerCN string // Issuer Common Name of leaf certificate + sans []string // Subject Alternative Names (DNS names) +} + +func tlsCheck(targetHost string, port string) (tlscertinfo, error) { + var info tlscertinfo conn, err := net.Dial("tcp", targetHost+":"+port) if err != nil { - return "", tlsbool, tlsvalid, errors.New("error connecting to TCP port") + return info, errors.New("error connecting to TCP port") } defer conn.Close() @@ -33,21 +45,28 @@ func tlsCheck(targetHost string, port string) (string, bool, bool, error) { InfoLogger.Println(Red("TLS handshake failed")) if strings.HasSuffix(err.Error(), "certificate name does not match input") { // Update TLS configuration - tlsbool = true + info.tlsok = true tlsconfig = &tls.Config{InsecureSkipVerify: true} } tlsConn = tls.Client(conn, tlsconfig) } else { - tlsbool = true - tlsvalid = true + info.tlsok = true + info.certvalid = true } // Check if TLS is available tlsstate := tlsConn.ConnectionState() + info.version = tlsversions[tlsstate.Version] if len(tlsstate.PeerCertificates) > 0 { - return tlsversions[tlsstate.Version], tlsbool, tlsvalid, nil + leaf := tlsstate.PeerCertificates[0] + info.expiry = leaf.NotAfter + info.subjectCN = leaf.Subject.CommonName + info.issuerCN = leaf.Issuer.CommonName + info.sans = leaf.DNSNames } else { - return "TLS is not available", tlsbool, tlsvalid, nil + info.version = "TLS is not available" } + + return info, nil } diff --git a/tlsrpt.go b/tlsrpt.go new file mode 100644 index 0000000..14534cf --- /dev/null +++ b/tlsrpt.go @@ -0,0 +1,8 @@ +package main + +type tlsrpt struct { + tlsrptset bool + tlsrptfull string + v string + rua string +}