Implements the full site health auditor with four check groups: - SSL/TLS (certificate validity, expiry, chain, TLS version, ciphers) - HTTP (redirect chain, security headers, response time) - DNS (A/AAAA, MX, SPF, DKIM, DMARC, DNSSEC) - Infrastructure (CDN detection, common port probes) Includes CLI with --checks filter, --json and --out flags, cross-compile Makefile, and GitLab CI pipeline. Signed-off-by: Blake Ridgway <blake@blakeridgway.com>
170 lines
4.9 KiB
Go
170 lines
4.9 KiB
Go
package ssl
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"net"
|
|
"time"
|
|
|
|
"arcline-audit/internal/types"
|
|
)
|
|
|
|
// insecureCipherSuites is a set of cipher suite IDs considered weak.
|
|
var insecureCipherSuites = map[uint16]bool{
|
|
tls.TLS_RSA_WITH_RC4_128_SHA: true,
|
|
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA: true,
|
|
tls.TLS_RSA_WITH_AES_128_CBC_SHA: true,
|
|
tls.TLS_RSA_WITH_AES_256_CBC_SHA: true,
|
|
tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA: true,
|
|
tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA: true,
|
|
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA: true,
|
|
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA: true,
|
|
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA: true,
|
|
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA: true,
|
|
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA: true,
|
|
tls.TLS_RSA_WITH_AES_128_GCM_SHA256: true,
|
|
tls.TLS_RSA_WITH_AES_256_GCM_SHA384: true,
|
|
}
|
|
|
|
// Run performs all SSL/TLS checks for the given domain.
|
|
func Run(domain string) types.SSLResult {
|
|
addr := net.JoinHostPort(domain, "443")
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
conn, err := tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{
|
|
InsecureSkipVerify: true,
|
|
})
|
|
if err != nil {
|
|
return types.SSLResult{
|
|
Checks: []types.CheckResult{
|
|
{Status: types.StatusFail, Message: fmt.Sprintf("failed to connect: %v", err)},
|
|
},
|
|
}
|
|
}
|
|
defer conn.Close()
|
|
|
|
state := conn.ConnectionState()
|
|
var checks []types.CheckResult
|
|
|
|
// Certificate validity and details
|
|
checks = append(checks, checkCertificates(state.PeerCertificates)...)
|
|
|
|
// TLS version
|
|
checks = append(checks, checkTLSVersion(state.Version)...)
|
|
|
|
// Cipher suite
|
|
checks = append(checks, checkCipherSuite(state.CipherSuite)...)
|
|
|
|
result := types.SSLResult{Checks: checks}
|
|
if len(state.PeerCertificates) > 0 {
|
|
cert := state.PeerCertificates[0]
|
|
result.Issuer = cert.Issuer.String()
|
|
result.Expiry = cert.NotAfter
|
|
result.DaysLeft = int(time.Until(cert.NotAfter).Hours() / 24)
|
|
}
|
|
result.TLSVersion = tlsVersionName(state.Version)
|
|
return result
|
|
}
|
|
|
|
func checkCertificates(certs []*x509.Certificate) []types.CheckResult {
|
|
var checks []types.CheckResult
|
|
|
|
if len(certs) == 0 {
|
|
checks = append(checks, types.CheckResult{
|
|
Status: types.StatusFail, Message: "no certificates presented",
|
|
})
|
|
return checks
|
|
}
|
|
|
|
leaf := certs[0]
|
|
now := time.Now()
|
|
|
|
// Check expiry
|
|
if now.After(leaf.NotAfter) {
|
|
checks = append(checks, types.CheckResult{
|
|
Status: types.StatusFail,
|
|
Message: fmt.Sprintf("certificate expired on %s", leaf.NotAfter.Format("2006-01-02")),
|
|
})
|
|
} else {
|
|
daysLeft := int(time.Until(leaf.NotAfter).Hours() / 24)
|
|
if daysLeft < 30 {
|
|
checks = append(checks, types.CheckResult{
|
|
Status: types.StatusWarn,
|
|
Message: fmt.Sprintf("certificate expires in %d days (%s)", daysLeft, leaf.NotAfter.Format("2006-01-02")),
|
|
})
|
|
} else {
|
|
checks = append(checks, types.CheckResult{
|
|
Status: types.StatusOK,
|
|
Message: fmt.Sprintf("valid certificate (%s)", leaf.NotAfter.Format("2006-01-02")),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Check self-signed
|
|
if leaf.Issuer.String() == leaf.Subject.String() {
|
|
checks = append(checks, types.CheckResult{
|
|
Status: types.StatusWarn, Message: "self-signed certificate",
|
|
})
|
|
} else {
|
|
checks = append(checks, types.CheckResult{
|
|
Status: types.StatusOK, Message: "not self-signed",
|
|
})
|
|
}
|
|
|
|
// Check chain completeness
|
|
if len(certs) >= 2 {
|
|
checks = append(checks, types.CheckResult{
|
|
Status: types.StatusOK, Message: "certificate chain is complete",
|
|
})
|
|
} else {
|
|
checks = append(checks, types.CheckResult{
|
|
Status: types.StatusWarn, Message: "certificate chain may be incomplete",
|
|
})
|
|
}
|
|
|
|
return checks
|
|
}
|
|
|
|
func checkTLSVersion(vers uint16) []types.CheckResult {
|
|
name := tlsVersionName(vers)
|
|
switch vers {
|
|
case tls.VersionTLS13:
|
|
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("TLS %s", name)}}
|
|
case tls.VersionTLS12:
|
|
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("TLS %s", name)}}
|
|
case tls.VersionTLS11, tls.VersionTLS10:
|
|
return []types.CheckResult{{Status: types.StatusWarn, Message: fmt.Sprintf("TLS %s is insecure", name)}}
|
|
case 0:
|
|
return []types.CheckResult{{Status: types.StatusFail, Message: "unknown TLS version"}}
|
|
default:
|
|
return []types.CheckResult{{Status: types.StatusInfo, Message: fmt.Sprintf("TLS %s", name)}}
|
|
}
|
|
}
|
|
|
|
func checkCipherSuite(id uint16) []types.CheckResult {
|
|
name := tls.CipherSuiteName(id)
|
|
if name == "" {
|
|
name = fmt.Sprintf("unknown (0x%04X)", id)
|
|
}
|
|
if insecureCipherSuites[id] {
|
|
return []types.CheckResult{{Status: types.StatusWarn, Message: fmt.Sprintf("weak cipher suite: %s", name)}}
|
|
}
|
|
return []types.CheckResult{{Status: types.StatusOK, Message: fmt.Sprintf("cipher suite: %s", name)}}
|
|
}
|
|
|
|
func tlsVersionName(v uint16) string {
|
|
switch v {
|
|
case tls.VersionTLS10:
|
|
return "1.0"
|
|
case tls.VersionTLS11:
|
|
return "1.1"
|
|
case tls.VersionTLS12:
|
|
return "1.2"
|
|
case tls.VersionTLS13:
|
|
return "1.3"
|
|
default:
|
|
return fmt.Sprintf("0x%04X", v)
|
|
}
|
|
}
|
|
|