tlsenum package probes a remote endpoint with one ClientHello per (version, cipher) pair via utls, so the checker can report the exact set the server accepts rather than only the suite Go's stdlib happens to negotiate. Probe accepts an Upgrader callback so STARTTLS dialects plug in without tlsenum learning about them; the checker bridges its existing dialect registry through upgraderFor.
197 lines
5.8 KiB
Go
197 lines
5.8 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
|
)
|
|
|
|
// hasEnum returns true when at least one probe carries enumeration data.
|
|
// Rules use this to short-circuit to "skipped" when the user hasn't enabled
|
|
// the enumerate option (rather than falsely emitting a "passing" verdict).
|
|
func hasEnum(data *TLSData) bool {
|
|
for _, p := range data.Probes {
|
|
if p.Enum != nil && len(p.Enum.Versions) > 0 {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// versionEnumerationRule reports the full set of protocol versions accepted
|
|
// by each endpoint, and flags any acceptance below the TLS 1.2 floor — the
|
|
// regular handshake rule only sees the *negotiated* version, so a server
|
|
// that still accepts TLS 1.0 alongside TLS 1.3 would otherwise look healthy.
|
|
type versionEnumerationRule struct{}
|
|
|
|
func (r *versionEnumerationRule) Name() string { return "tls.enum.versions" }
|
|
func (r *versionEnumerationRule) Description() string {
|
|
return "Flags endpoints that still accept TLS versions below TLS 1.2 (requires the enumerate option)."
|
|
}
|
|
|
|
func (r *versionEnumerationRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
|
data, errSt := loadData(ctx, obs)
|
|
if errSt != nil {
|
|
return []sdk.CheckState{*errSt}
|
|
}
|
|
if len(data.Probes) == 0 {
|
|
return []sdk.CheckState{emptyCaseState("tls.enum.versions.no_endpoints")}
|
|
}
|
|
if !hasEnum(data) {
|
|
return []sdk.CheckState{unknownState(
|
|
"tls.enum.versions.skipped",
|
|
"TLS version/cipher enumeration was not run for any endpoint (enable the enumerateCiphers option).",
|
|
)}
|
|
}
|
|
|
|
var out []sdk.CheckState
|
|
anyEnum := false
|
|
for _, ref := range sortedRefs(data) {
|
|
p := data.Probes[ref]
|
|
if p.Enum == nil || len(p.Enum.Versions) == 0 {
|
|
continue
|
|
}
|
|
anyEnum = true
|
|
|
|
var legacy []string
|
|
for _, v := range p.Enum.Versions {
|
|
if v.Version < tls.VersionTLS12 {
|
|
legacy = append(legacy, v.Name)
|
|
}
|
|
}
|
|
if len(legacy) == 0 {
|
|
continue
|
|
}
|
|
sort.Strings(legacy)
|
|
out = append(out, sdk.CheckState{
|
|
Status: sdk.StatusWarn,
|
|
Code: "tls.enum.versions.legacy_accepted",
|
|
Subject: subjectOf(p),
|
|
Message: fmt.Sprintf("Endpoint accepts legacy protocol version(s): %s.", strings.Join(legacy, ", ")),
|
|
Meta: metaOf(p),
|
|
})
|
|
}
|
|
if !anyEnum {
|
|
return []sdk.CheckState{unknownState(
|
|
"tls.enum.versions.skipped",
|
|
"No endpoint produced enumeration data.",
|
|
)}
|
|
}
|
|
if len(out) == 0 {
|
|
return []sdk.CheckState{passState(
|
|
"tls.enum.versions.ok",
|
|
"No endpoint accepts a protocol version below TLS 1.2.",
|
|
)}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// weakCipherRule flags endpoints that accept cipher suites widely considered
|
|
// broken or insecure: NULL, anonymous, EXPORT, RC4, 3DES, and any other CBC
|
|
// suite using SHA-1 in MAC-then-encrypt mode is *not* flagged here because
|
|
// real-world servers still need them for legacy clients; this rule limits
|
|
// itself to the set with no defensible use in 2026.
|
|
type weakCipherRule struct{}
|
|
|
|
func (r *weakCipherRule) Name() string { return "tls.enum.ciphers" }
|
|
func (r *weakCipherRule) Description() string {
|
|
return "Flags endpoints that accept broken cipher suites (NULL, anonymous, EXPORT, RC4, 3DES)."
|
|
}
|
|
|
|
// classifyCipher returns a non-empty category when the named cipher belongs
|
|
// to a class with no defensible modern use. The check is by substring on the
|
|
// IANA name because every entry follows the TLS_<KX>_WITH_<CIPHER>_<MAC>
|
|
// convention.
|
|
func classifyCipher(name string) string {
|
|
upper := strings.ToUpper(name)
|
|
switch {
|
|
case strings.Contains(upper, "_NULL_"), strings.HasSuffix(upper, "_NULL"):
|
|
return "NULL"
|
|
case strings.Contains(upper, "_ANON_"):
|
|
return "anonymous"
|
|
case strings.Contains(upper, "_EXPORT_"):
|
|
return "EXPORT"
|
|
case strings.Contains(upper, "_RC4_"):
|
|
return "RC4"
|
|
case strings.Contains(upper, "_3DES_"), strings.Contains(upper, "_DES_"):
|
|
return "3DES/DES"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (r *weakCipherRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
|
data, errSt := loadData(ctx, obs)
|
|
if errSt != nil {
|
|
return []sdk.CheckState{*errSt}
|
|
}
|
|
if len(data.Probes) == 0 {
|
|
return []sdk.CheckState{emptyCaseState("tls.enum.ciphers.no_endpoints")}
|
|
}
|
|
if !hasEnum(data) {
|
|
return []sdk.CheckState{unknownState(
|
|
"tls.enum.ciphers.skipped",
|
|
"TLS version/cipher enumeration was not run for any endpoint (enable the enumerateCiphers option).",
|
|
)}
|
|
}
|
|
|
|
var out []sdk.CheckState
|
|
anyEnum := false
|
|
for _, ref := range sortedRefs(data) {
|
|
p := data.Probes[ref]
|
|
if p.Enum == nil || len(p.Enum.Versions) == 0 {
|
|
continue
|
|
}
|
|
anyEnum = true
|
|
|
|
// Aggregate by category so a server accepting six EXPORT suites
|
|
// produces one finding, not six.
|
|
byCategory := map[string][]string{}
|
|
for _, v := range p.Enum.Versions {
|
|
for _, c := range v.Ciphers {
|
|
cat := classifyCipher(c.Name)
|
|
if cat == "" {
|
|
continue
|
|
}
|
|
byCategory[cat] = append(byCategory[cat], c.Name)
|
|
}
|
|
}
|
|
if len(byCategory) == 0 {
|
|
continue
|
|
}
|
|
cats := make([]string, 0, len(byCategory))
|
|
for c := range byCategory {
|
|
cats = append(cats, c)
|
|
}
|
|
sort.Strings(cats)
|
|
parts := make([]string, 0, len(cats))
|
|
for _, c := range cats {
|
|
parts = append(parts, fmt.Sprintf("%s (%d)", c, len(byCategory[c])))
|
|
}
|
|
meta := metaOf(p)
|
|
meta["weak_ciphers"] = byCategory
|
|
out = append(out, sdk.CheckState{
|
|
Status: sdk.StatusWarn,
|
|
Code: "tls.enum.ciphers.weak_accepted",
|
|
Subject: subjectOf(p),
|
|
Message: "Endpoint accepts broken cipher suites: " + strings.Join(parts, ", ") + ".",
|
|
Meta: meta,
|
|
})
|
|
}
|
|
if !anyEnum {
|
|
return []sdk.CheckState{unknownState(
|
|
"tls.enum.ciphers.skipped",
|
|
"No endpoint produced enumeration data.",
|
|
)}
|
|
}
|
|
if len(out) == 0 {
|
|
return []sdk.CheckState{passState(
|
|
"tls.enum.ciphers.ok",
|
|
"No endpoint accepts a known-broken cipher suite (NULL/anonymous/EXPORT/RC4/3DES).",
|
|
)}
|
|
}
|
|
return out
|
|
}
|