diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..0729b38 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,27 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Go Build Cert Checker + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.22' + + - name: Build + run: go build -ldflags "-s -w -X main.sha1ver=`git rev-parse HEAD` -X main.buildTime=`date +'%Y-%m-%d_%T%Z'`" -o certchecker_linux main.go + env: + CGO_ENABLED: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ad92066 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: Release Cert Checker + +on: + push: + tags: + - '*' + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' + + - name: Build + run: go build -ldflags "-s -w -X main.sha1ver=`git rev-parse HEAD` -X main.buildTime=`date +'%Y-%m-%d_%T%Z'`" -o certchecker_linux main.go + env: + CGO_ENABLED: 0 + + - uses: ncipollo/release-action@v1 + with: + artifacts: "certchecker_linux" + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c159d5f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +certs*.html +tlsscan_* +*.json +Makefile \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/certs_html.tmpl b/certs_html.tmpl new file mode 100644 index 0000000..862922a --- /dev/null +++ b/certs_html.tmpl @@ -0,0 +1,54 @@ + + + + +

Certs

+ + + + + + + + + + + + + + + +{{range .TlsCerts }} + + + + + + + + + + + + + +{{end}} +
HostIPHostDNSHostPortHostname VerifiedSNI VerifiedSubject CNDNS NamesIP AddressesIssuerExpiryExpired
{{.HostIP}}{{.HostDNS}}{{.HostPort}}{{.HostNameVerified}}{{.SNIVerified}}{{.SubjectCN}}{{.DNSNames}}{{.IPAddresses}}{{.Issuer}}{{.Expiry}}{{.Expired}}
+ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..69dda72 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/saschamonteiro/certchecker + +go 1.22 + +require ( + github.com/alexeyco/simpletable v1.0.0 + golang.org/x/sync v0.7.0 +) + +require ( + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0855dbb --- /dev/null +++ b/go.sum @@ -0,0 +1,11 @@ +github.com/alexeyco/simpletable v1.0.0 h1:ZQ+LvJ4bmoeHb+dclF64d0LX+7QAi7awsfCrptZrpHk= +github.com/alexeyco/simpletable v1.0.0/go.mod h1:VJWVTtGUnW7EKbMRH8cE13SigKGx/1fO2SeeOiGeBkk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..d11e747 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,112 @@ +package app + +import ( + "context" + "embed" + "fmt" + "net" + "sort" + "strings" + + "github.com/saschamonteiro/certchecker/internal/certs" + "github.com/saschamonteiro/certchecker/internal/output" + "golang.org/x/sync/errgroup" +) + +func StartTlsCollect(cidrAddressList string, portList string, skipNoDnsFound bool, Assets embed.FS, htmlOut string, jsonOut string) { + cidrAdd := strings.Split(cidrAddressList, ",") + allHosts := []string{} + for _, cidrAddress := range cidrAdd { + hosts, _ := hostsFromCIDR(cidrAddress) + allHosts = append(allHosts, hosts...) + } + ports := strings.Split(portList, ",") + g, ctx := errgroup.WithContext(context.Background()) + resultChan := make(chan []certs.TlsCert, len(allHosts)*len(ports)) + result := make([]certs.TlsCert, 0) + g.SetLimit(128) + fmt.Printf("Scanning CIDRs:%v [ports:%s], please wait ", cidrAddressList, portList) + for _, host := range allHosts { + a := host + g.Go(func() error { + cres := findHostCerts(a, ports, skipNoDnsFound) + select { + case resultChan <- cres: + case <-ctx.Done(): + return context.Canceled + default: + } + return nil + }) + } + if err := g.Wait(); err != nil { + fmt.Printf("ERROR: %+v\n", err) + return + } + + close(resultChan) + for val := range resultChan { + result = append(result, val...) + } + fmt.Printf("\nFound %v TLS Certs\n", len(result)) + sort.Slice(result, func(i, j int) bool { return result[i].Expiry.Before(result[j].Expiry) }) + + output.ShowCertTable(result) + + if htmlOut != "" { + output.CreateOutFile(result, htmlOut, "certs_html.tmpl", Assets) + } + if jsonOut != "" { + output.CreateJsonFile(result, jsonOut) + } +} + +func findHostCerts(ip string, ports []string, skipNoDnsFound bool) []certs.TlsCert { + serveraddr, err := net.LookupAddr(ip) + cres := []certs.TlsCert{} + if err == nil && len(serveraddr) > 0 { + serverN := strings.TrimRight(serveraddr[0], ".") + for _, port := range ports { + c := certs.CheckCert(serverN, port, ip) + if c.Issuer != "" { + cres = append(cres, c) + } + } + } else { + if skipNoDnsFound { + return nil + } + for _, port := range ports { + c := certs.CheckCert(ip, port, ip) + if c.Issuer != "" { + cres = append(cres, c) + } + } + } + return cres +} + +func hostsFromCIDR(cidr string) ([]string, error) { + ip, ipnet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, err + } + + var ips []string + for ip := ip.Mask(ipnet.Mask); ipnet.Contains(ip); inc(ip) { + ips = append(ips, ip.String()) + } + if len(ips) == 1 { + return ips, nil + } + return ips[1 : len(ips)-1], nil +} + +func inc(ip net.IP) { + for j := len(ip) - 1; j >= 0; j-- { + ip[j]++ + if ip[j] > 0 { + break + } + } +} diff --git a/internal/certs/certs.go b/internal/certs/certs.go new file mode 100644 index 0000000..b71dfd8 --- /dev/null +++ b/internal/certs/certs.go @@ -0,0 +1,99 @@ +package certs + +import ( + "crypto/tls" + "fmt" + "net" + "strings" + "time" +) + +type TlsCert struct { + HostNameVerified bool `json:"hostnameVerified"` + SubjectCN string `json:"subjectCN"` + DNSNames string `json:"dnsNames"` + IPAddresses string `json:"ipAddresses"` + Issuer string `json:"issuer"` + Expiry time.Time `json:"expiry"` + Expired bool `json:"expired"` + HostDNS string `json:"hostDNS"` + HostIP string `json:"hostIP"` + HostPort string `json:"hostPort"` + SNIVerified bool `json:"sniVerified"` +} +type TlsPageData struct { + TlsCerts []TlsCert +} + +func CheckCert(server, port, ip string) TlsCert { + hostnameVerified := false + SNIVerified := false + conf := &tls.Config{InsecureSkipVerify: false} + if server != ip { + conf.ServerName = server + } + // fmt.Printf("\n --> Start: %s\n", server) + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 1 * time.Second}, "tcp", ip+":"+port, conf) + if err != nil { + // fmt.Printf("DialWithSNI Error %v\n", err) + // fmt.Println("SecureTLS failed, Try InsecureSkipVerify", err) + conn, err = tls.DialWithDialer(&net.Dialer{Timeout: 1 * time.Second}, "tcp", ip+":"+port, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + if strings.Contains(err.Error(), "network is unreachable") || strings.Contains(err.Error(), "i/o timeout") || strings.Contains(err.Error(), "connection refused") || strings.Contains(err.Error(), "no such host") || strings.Contains(err.Error(), "no route to host") { + return TlsCert{} + } else { + fmt.Printf("Server doesn't support SSL certificate err: %v\n", err.Error()) + } + return TlsCert{} + } else { + // fmt.Println("trying hostname validation") + if strings.Split(server, ":")[0] == conn.ConnectionState().PeerCertificates[0].Subject.CommonName { + hostnameVerified = true + } else { + for _, dns := range conn.ConnectionState().PeerCertificates[0].DNSNames { + if strings.Split(server, ":")[0] == dns { + hostnameVerified = true + } + } + } + } + } else { + + err = conn.VerifyHostname(strings.Split(server, ":")[0]) + if err != nil { + fmt.Printf("Hostname doesn't match with certificate: %v\n", err.Error()) + } else { + // fmt.Printf("SNIVerified", server, conn.ConnectionState().PeerCertificates[0].Subject.CommonName) + hostnameVerified = true + SNIVerified = true + } + + } + // fmt.Printf("ServerName: %v\n", conn.ConnectionState().) + expiry := conn.ConnectionState().PeerCertificates[0].NotAfter + expired := expiry.Before(time.Now()) + // expiredString := string(colorGreen) + "false" + string(colorReset) + // if expired { + // expiredString = string(colorRed) + "true" + string(colorReset) + // } + // fmt.Printf("HostnameVerified: %v\nSubject CN: %v\nDNSNames: %v\nIPAddr: %v\nIssuer: %s\nExpiry: %v\nExpired: %v\n\n", hostnameVerified, conn.ConnectionState().PeerCertificates[0].Subject.CommonName, conn.ConnectionState().PeerCertificates[0].DNSNames, conn.ConnectionState().PeerCertificates[0].IPAddresses, conn.ConnectionState().PeerCertificates[0].Issuer, expiry.Format(time.RFC850), expiredString) + // fmt.Printf("-- %+v\n", conn.ConnectionState().PeerCertificates[0]) + cert := TlsCert{ + HostNameVerified: hostnameVerified, + SubjectCN: conn.ConnectionState().PeerCertificates[0].Subject.CommonName, + DNSNames: fmt.Sprintf("%s", conn.ConnectionState().PeerCertificates[0].DNSNames), + IPAddresses: fmt.Sprintf("%s", conn.ConnectionState().PeerCertificates[0].IPAddresses), + Issuer: conn.ConnectionState().PeerCertificates[0].Issuer.String(), + Expiry: expiry, + Expired: expired, + HostDNS: server, + HostIP: ip, + HostPort: port, + SNIVerified: SNIVerified, + } + if ip == server { + cert.HostDNS = "-" + } + fmt.Printf(".") + return cert +} diff --git a/internal/output/output.go b/internal/output/output.go new file mode 100644 index 0000000..1a9b21e --- /dev/null +++ b/internal/output/output.go @@ -0,0 +1,115 @@ +package output + +import ( + "embed" + "encoding/json" + "fmt" + "os" + "text/template" + "time" + + "github.com/alexeyco/simpletable" + "github.com/saschamonteiro/certchecker/internal/certs" +) + +var ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + // colorYellow = "\033[33m" +) + +func ShowCertTable(data []certs.TlsCert) { + //table + table := simpletable.New() + table.Header = &simpletable.Header{ + Cells: []*simpletable.Cell{ + {Align: simpletable.AlignLeft, Text: "HostIP:Port"}, + {Align: simpletable.AlignLeft, Text: "HostDNS (reverse)"}, + {Align: simpletable.AlignLeft, Text: "DNS Match Cert"}, + {Align: simpletable.AlignLeft, Text: "SNI Verified"}, + {Align: simpletable.AlignLeft, Text: "CertDNSNames"}, + {Align: simpletable.AlignLeft, Text: "Subject Common Name"}, + {Align: simpletable.AlignLeft, Text: "Issuer"}, + {Align: simpletable.AlignLeft, Text: "Expiry ↓"}, + {Align: simpletable.AlignLeft, Text: "Expired"}, + }, + } + for _, cert := range data { + table.Body.Cells = append(table.Body.Cells, []*simpletable.Cell{ + {Align: simpletable.AlignLeft, Text: fmt.Sprintf("%s:%s", cert.HostIP, cert.HostPort)}, + {Align: simpletable.AlignLeft, Text: cert.HostDNS}, + {Align: simpletable.AlignLeft, Text: valid(cert.HostNameVerified)}, + {Align: simpletable.AlignLeft, Text: valid(cert.SNIVerified)}, + {Align: simpletable.AlignLeft, Text: truncateText(cert.DNSNames, 20)}, + {Align: simpletable.AlignLeft, Text: cert.SubjectCN}, + {Align: simpletable.AlignLeft, Text: truncateText(cert.Issuer, 30)}, + {Align: simpletable.AlignLeft, Text: cert.Expiry.Local().String()}, + {Align: simpletable.AlignLeft, Text: exp(cert.Expired)}, + }) + } + table.SetStyle(simpletable.StyleUnicode) + fmt.Println(table.String()) +} + +func CreateOutFile(data []certs.TlsCert, fileName string, templateFile string, Assets embed.FS) { + // t, _ := template.ParseFiles("certs.tmpl") + t, _ := template.ParseFS(Assets, templateFile) + f, err := os.Create(fileName) + if err != nil { + fmt.Println("error create file: ", err) + return + } + defer f.Close() + err = t.Execute(f, certs.TlsPageData{ + TlsCerts: data, + }) + if err != nil { + fmt.Printf("error execute template: %v\n", err) + return + } + +} + +type Meta struct { + Certs []certs.TlsCert `json:"certs"` + DateTime time.Time `json:"dateTime"` +} + +func CreateJsonFile(data []certs.TlsCert, fileName string) { + f, err := os.Create(fileName) + if err != nil { + fmt.Println("error create file: ", err) + return + } + defer f.Close() + meta := Meta{ + Certs: data, + DateTime: time.Now(), + } + jsonData, err := json.MarshalIndent(meta, "", " ") + if err != nil { + fmt.Println("error parsing data to json: ", err) + return + } + f.Write(jsonData) +} + +func exp(s bool) string { + if s { + return fmt.Sprintf("%s%v%s", colorRed, s, colorReset) + } + return fmt.Sprintf("%s%v%s", colorGreen, s, colorReset) +} +func valid(s bool) string { + if !s { + return fmt.Sprintf("%s%v%s", colorRed, s, colorReset) + } + return fmt.Sprintf("%s%v%s", colorGreen, s, colorReset) +} +func truncateText(s string, max int) string { + if max >= len(s) { + return s + } + return s[:max] + "..." +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..1aaf02a --- /dev/null +++ b/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "embed" + "flag" + "fmt" + "os" + + "github.com/saschamonteiro/certchecker/internal/app" +) + +//go:embed *.tmpl +var Assets embed.FS + +var sha1ver string +var buildTime string + +func main() { + + cidrAddressList := flag.String("cidr", "192.168.10.0/24,192.168.11.0/24", "network cidr list") + portList := flag.String("ports", "443,636,587,8443", "tcp port list") + skipNoDnsFound := flag.Bool("skipnodns", false, "skip no dns found") + htmlOut := flag.String("html", "", "html output file") + jsonOut := flag.String("json", "", "json output file") + version := flag.Bool("version", false, "version") + if *version { + fmt.Printf("version: %s\n", sha1ver) + fmt.Printf("build time: %s\n", buildTime) + os.Exit(0) + } + flag.Parse() + + app.StartTlsCollect(*cidrAddressList, *portList, *skipNoDnsFound, Assets, *htmlOut, *jsonOut) + +}