checker-tls/checker/rules_enumeration.go
Pierre-Olivier Mercier a9f37c79cf Add tlsenum package and add version/cipher enumeration into the checker
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.
2026-04-29 13:35:29 +07:00

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
}