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
+
+
+HostIP |
+HostDNS |
+HostPort |
+Hostname Verified |
+SNI Verified |
+Subject CN |
+DNS Names |
+IP Addresses |
+Issuer |
+Expiry |
+Expired |
+
+
+{{range .TlsCerts }}
+
+{{.HostIP}} |
+{{.HostDNS}} |
+{{.HostPort}} |
+{{.HostNameVerified}} |
+{{.SNIVerified}} |
+{{.SubjectCN}} |
+{{.DNSNames}} |
+{{.IPAddresses}} |
+{{.Issuer}} |
+{{.Expiry}} |
+{{.Expired}} |
+
+{{end}}
+
+
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)
+
+}