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.
This commit is contained in:
nemunaire 2026-04-29 13:34:27 +07:00
commit a9f37c79cf
18 changed files with 1569 additions and 5 deletions

View file

@ -22,6 +22,7 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
timeoutMs = DefaultProbeTimeoutMs
}
timeout := time.Duration(timeoutMs) * time.Millisecond
enumerate := sdk.GetBoolOption(opts, OptionEnumerateCiphers, false)
entries, warnings := contract.ParseEntries(raw)
for _, w := range warnings {
@ -40,15 +41,36 @@ func (p *tlsProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, MaxConcurrentProbes)
dispatch:
for _, e := range entries {
select {
case sem <- struct{}{}:
case <-ctx.Done():
break dispatch
}
wg.Add(1)
sem <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-sem }()
pr := probe(ctx, e.Endpoint, timeout)
log.Printf("checker-tls: %s %s:%d → tls=%s handshake_ok=%t elapsed=%dms err=%q",
pr.Type, pr.Host, pr.Port, pr.TLSVersion, pr.TLSHandshakeOK, pr.ElapsedMS, pr.Error)
if enumerate && pr.TLSHandshakeOK {
enumRes, skipReason := enumerateEndpoint(ctx, e.Endpoint, enumerationBudget)
switch {
case enumRes != nil && enumRes.Skipped != "":
pr.Enum = enumRes
log.Printf("checker-tls: enum %s:%d → error: %s (duration=%dms)",
pr.Host, pr.Port, enumRes.Skipped, enumRes.DurationMS)
case enumRes != nil:
pr.Enum = enumRes
log.Printf("checker-tls: enum %s:%d → versions=%d duration=%dms",
pr.Host, pr.Port, len(enumRes.Versions), enumRes.DurationMS)
case skipReason != "":
log.Printf("checker-tls: enum %s:%d → skipped: %s",
pr.Host, pr.Port, skipReason)
}
}
mu.Lock()
probes[e.Ref] = pr
mu.Unlock()

View file

@ -29,6 +29,13 @@ func (p *tlsProvider) Definition() *sdk.CheckerDefinition {
Description: "Maximum time allowed for dial + STARTTLS + TLS handshake on a single endpoint.",
Default: float64(DefaultProbeTimeoutMs),
},
{
Id: OptionEnumerateCiphers,
Type: "boolean",
Label: "Enumerate accepted TLS versions and cipher suites",
Description: "When enabled, each direct-TLS endpoint is swept with one ClientHello per (version, cipher) pair to discover the exact set the server accepts. Adds ~50 handshakes per endpoint.",
Default: false,
},
},
RunOpts: []sdk.CheckerOptionDocumentation{
{

68
checker/enumerate.go Normal file
View file

@ -0,0 +1,68 @@
package checker
import (
"context"
"net"
"strconv"
"strings"
"time"
"git.happydns.org/checker-tls/contract"
"git.happydns.org/checker-tls/tlsenum"
)
// enumerationProbeTimeout caps each individual sub-probe. It is intentionally
// shorter than the main probe timeout: a sweep does dozens of handshakes and
// most rejections come back in tens of ms, so 3s is enough to absorb a slow
// network without dragging the total cost.
const enumerationProbeTimeout = 3 * time.Second
// enumerateEndpoint runs a (version × cipher) sweep against an endpoint —
// direct TLS or STARTTLS — and returns the result in the wire-format consumed
// by rules. It returns (nil, "<reason>") to signal the sweep was deliberately
// skipped.
func enumerateEndpoint(ctx context.Context, ep contract.TLSEndpoint, totalBudget time.Duration) (*TLSEnumeration, string) {
host := strings.TrimSuffix(ep.Host, ".")
addr := net.JoinHostPort(host, strconv.Itoa(int(ep.Port)))
sni := ep.SNI
if sni == "" {
sni = host
}
upgrader, ok := upgraderFor(ep.STARTTLS, sni)
if !ok {
return nil, "unsupported starttls dialect: " + ep.STARTTLS
}
sweepCtx := ctx
if totalBudget > 0 {
var cancel context.CancelFunc
sweepCtx, cancel = context.WithTimeout(ctx, totalBudget)
defer cancel()
}
start := time.Now()
res, err := tlsenum.Enumerate(sweepCtx, addr, sni, tlsenum.EnumerateOptions{
ProbeTimeout: enumerationProbeTimeout,
Upgrader: upgrader,
})
elapsed := time.Since(start).Milliseconds()
if err != nil {
return &TLSEnumeration{Skipped: "enumeration error: " + err.Error(), DurationMS: elapsed}, ""
}
out := &TLSEnumeration{DurationMS: elapsed}
for _, v := range res.SupportedVersions {
ev := EnumVersion{Version: v, Name: tlsenum.VersionName(v)}
for _, c := range res.CiphersByVersion[v] {
ev.Ciphers = append(ev.Ciphers, EnumCipher{ID: c.ID, Name: c.Name})
}
out.Versions = append(out.Versions, ev)
}
return out, ""
}
// enumerationBudget is the upper bound we give one endpoint's sweep. ~50
// handshakes × enumerationProbeTimeout would be 2-3 minutes worst case; we
// cap at 60s so a black-holing target can't stall the whole collect run.
const enumerationBudget = 60 * time.Second

198
checker/enumerate_test.go Normal file
View file

@ -0,0 +1,198 @@
package checker
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"strconv"
"testing"
"time"
"git.happydns.org/checker-tls/contract"
)
// startEnumTestServer spins up a TCP listener that, for every accepted
// connection: (1) optionally drives a fake STARTTLS dialect handshake, then
// (2) lets the standard library terminate TLS with the provided cert. It
// keeps accepting until the test closes the listener.
//
// We use the stdlib tls.Server (not utls) on the server side: the point of
// these tests is to exercise the *checker* glue (upgraderFor + enumerate)
// against the real client-side code, not to replay tlsenum's internals.
func startEnumTestServer(t *testing.T, withSTARTTLS bool, cert tls.Certificate) net.Listener {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
go handleEnumConn(c, withSTARTTLS, cert)
}
}()
return ln
}
func handleEnumConn(c net.Conn, withSTARTTLS bool, cert tls.Certificate) {
defer c.Close()
if withSTARTTLS {
// Pretend to be SMTP: 220 banner, EHLO ack, STARTTLS ack. The
// implementation of starttlsSMTP only requires the server to
// advertise STARTTLS in its EHLO response and to reply with a 2xx
// to the STARTTLS verb — exact verbs come from RFC 3207.
if _, err := io.WriteString(c, "220 enum.test ESMTP\r\n"); err != nil {
return
}
buf := make([]byte, 1024)
// EHLO line
if _, err := c.Read(buf); err != nil {
return
}
if _, err := io.WriteString(c, "250-enum.test\r\n250 STARTTLS\r\n"); err != nil {
return
}
// STARTTLS line
if _, err := c.Read(buf); err != nil {
return
}
if _, err := io.WriteString(c, "220 ready\r\n"); err != nil {
return
}
}
tc := tls.Server(c, &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12, // narrow surface so the sweep is fast
})
defer tc.Close()
_ = tc.Handshake()
}
// enumTestCert is a one-time self-signed ECDSA cert reused across tests.
func enumTestCert(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "enum.test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
DNSNames: []string{"enum.test"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("createcert: %v", err)
}
keyDER, err := x509.MarshalECPrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
c, err := tls.X509KeyPair(
pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}),
)
if err != nil {
t.Fatalf("keypair: %v", err)
}
return c
}
func portOf(t *testing.T, ln net.Listener) uint16 {
t.Helper()
_, p, err := net.SplitHostPort(ln.Addr().String())
if err != nil {
t.Fatalf("split addr: %v", err)
}
n, err := strconv.ParseUint(p, 10, 16)
if err != nil {
t.Fatalf("parse port: %v", err)
}
return uint16(n)
}
// TestEnumerateEndpoint_DirectTLS asserts the sweep returns at least one
// supported version + cipher when the endpoint is plain TLS — proving the
// nil-upgrader path of upgraderFor wires correctly.
func TestEnumerateEndpoint_DirectTLS(t *testing.T) {
cert := enumTestCert(t)
ln := startEnumTestServer(t, false, cert)
defer ln.Close()
res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{
Host: "127.0.0.1",
Port: portOf(t, ln),
SNI: "enum.test",
}, 30*time.Second)
if skip != "" {
t.Fatalf("unexpected skip reason: %q", skip)
}
if res == nil || len(res.Versions) == 0 {
t.Fatalf("expected at least one supported version, got %+v", res)
}
gotTLS12 := false
for _, v := range res.Versions {
if v.Version == tls.VersionTLS12 && len(v.Ciphers) > 0 {
gotTLS12 = true
}
}
if !gotTLS12 {
t.Fatalf("expected TLS 1.2 with at least one cipher, got %+v", res.Versions)
}
}
// TestEnumerateEndpoint_SMTP_STARTTLS asserts the sweep drives the SMTP
// dialect upgrade on every sub-probe and still discovers ciphers — proving
// the upgraderFor("smtp", sni) path is wired into Enumerate.
func TestEnumerateEndpoint_SMTP_STARTTLS(t *testing.T) {
cert := enumTestCert(t)
ln := startEnumTestServer(t, true, cert)
defer ln.Close()
res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{
Host: "127.0.0.1",
Port: portOf(t, ln),
SNI: "enum.test",
STARTTLS: "smtp",
}, 60*time.Second)
if skip != "" {
t.Fatalf("unexpected skip reason: %q", skip)
}
if res == nil || len(res.Versions) == 0 {
t.Fatalf("expected at least one supported version through STARTTLS, got %+v", res)
}
}
// TestEnumerateEndpoint_UnknownDialect asserts an unsupported STARTTLS
// dialect is rejected with a non-empty skip reason and no result — the
// observation must record *why* enumeration didn't run, not silently report
// "no versions accepted".
func TestEnumerateEndpoint_UnknownDialect(t *testing.T) {
res, skip := enumerateEndpoint(context.Background(), contract.TLSEndpoint{
Host: "127.0.0.1",
Port: 1, // unreachable on purpose; we never get past the dialect check
STARTTLS: "no-such-dialect",
}, time.Second)
if res != nil {
t.Fatalf("expected nil result for unknown dialect, got %+v", res)
}
if skip == "" {
t.Fatalf("expected non-empty skip reason for unknown dialect")
}
}

View file

@ -25,6 +25,8 @@ func Rules() []sdk.CheckRule {
&expiryRule{},
&tlsVersionRule{},
&cipherSuiteRule{},
&versionEnumerationRule{},
&weakCipherRule{},
}
}

View file

@ -0,0 +1,197 @@
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
}

View file

@ -0,0 +1,135 @@
package checker
import (
"context"
"crypto/tls"
"encoding/json"
"strings"
"testing"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stubObs is a minimal ObservationGetter that serves a pre-built TLSData
// payload and ignores related lookups. It is local to this file rather than
// promoted to a shared helper to keep the rule tests self-contained.
type stubObs struct{ data TLSData }
func (s stubObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
if key != ObservationKeyTLSProbes {
return nil
}
raw, _ := json.Marshal(s.data)
return json.Unmarshal(raw, dest)
}
func (s stubObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
return nil, nil
}
func newProbeWithEnum(versions ...EnumVersion) TLSProbe {
return TLSProbe{
Host: "example.test", Port: 443, Endpoint: "example.test:443", Type: "tls",
TLSHandshakeOK: true, TLSVersionNum: tls.VersionTLS13,
Enum: &TLSEnumeration{Versions: versions},
}
}
func TestVersionEnumerationRule_Skipped_NoEnum(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{"a": {Host: "x", Port: 443, Endpoint: "x:443", Type: "tls", TLSHandshakeOK: true}},
CollectedAt: time.Now(),
}}
got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Code != "tls.enum.versions.skipped" {
t.Fatalf("want a single skipped state, got %+v", got)
}
}
func TestVersionEnumerationRule_OK_OnlyModern(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{
"a": newProbeWithEnum(
EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2"},
EnumVersion{Version: tls.VersionTLS13, Name: "TLS 1.3"},
),
},
}}
got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Status != sdk.StatusOK || got[0].Code != "tls.enum.versions.ok" {
t.Fatalf("want a single OK state, got %+v", got)
}
}
func TestVersionEnumerationRule_LegacyAccepted(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{
"a": newProbeWithEnum(
EnumVersion{Version: tls.VersionTLS10, Name: "TLS 1.0"},
EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2"},
),
},
}}
got := (&versionEnumerationRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Status != sdk.StatusWarn || got[0].Code != "tls.enum.versions.legacy_accepted" {
t.Fatalf("want a single warn state, got %+v", got)
}
if !strings.Contains(got[0].Message, "TLS 1.0") {
t.Fatalf("warn message should mention the legacy version, got %q", got[0].Message)
}
}
func TestClassifyCipher(t *testing.T) {
cases := map[string]string{
"TLS_RSA_WITH_NULL_SHA": "NULL",
"TLS_DH_anon_WITH_AES_128_CBC_SHA": "anonymous",
"TLS_RSA_EXPORT_WITH_RC4_40_MD5": "EXPORT",
"TLS_ECDHE_RSA_WITH_RC4_128_SHA": "RC4",
"TLS_RSA_WITH_3DES_EDE_CBC_SHA": "3DES/DES",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256": "",
"TLS_AES_256_GCM_SHA384": "",
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256": "",
}
for name, want := range cases {
if got := classifyCipher(name); got != want {
t.Errorf("classifyCipher(%q) = %q, want %q", name, got, want)
}
}
}
func TestWeakCipherRule_Detects(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{
"a": newProbeWithEnum(
EnumVersion{Version: tls.VersionTLS12, Name: "TLS 1.2", Ciphers: []EnumCipher{
{ID: 0xC02F, Name: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"},
{ID: 0x000A, Name: "TLS_RSA_WITH_3DES_EDE_CBC_SHA"},
{ID: 0x0005, Name: "TLS_RSA_WITH_RC4_128_SHA"},
}},
),
},
}}
got := (&weakCipherRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Status != sdk.StatusWarn || got[0].Code != "tls.enum.ciphers.weak_accepted" {
t.Fatalf("want a single weak warn state, got %+v", got)
}
if !strings.Contains(got[0].Message, "RC4") || !strings.Contains(got[0].Message, "3DES/DES") {
t.Fatalf("warn message should list the broken categories, got %q", got[0].Message)
}
}
func TestWeakCipherRule_OK_OnlyModern(t *testing.T) {
obs := stubObs{data: TLSData{
Probes: map[string]TLSProbe{
"a": newProbeWithEnum(
EnumVersion{Version: tls.VersionTLS13, Name: "TLS 1.3", Ciphers: []EnumCipher{
{ID: 0x1301, Name: "TLS_AES_128_GCM_SHA256"},
}},
),
},
}}
got := (&weakCipherRule{}).Evaluate(context.Background(), obs, nil)
if len(got) != 1 || got[0].Status != sdk.StatusOK || got[0].Code != "tls.enum.ciphers.ok" {
t.Fatalf("want a single OK state, got %+v", got)
}
}

View file

@ -48,3 +48,18 @@ var starttlsUpgraders = map[string]starttlsUpgrader{}
func registerStartTLS(protocol string, upgrader starttlsUpgrader) {
starttlsUpgraders[protocol] = upgrader
}
// upgraderFor returns a tlsenum-compatible upgrader callback for a given
// STARTTLS dialect, plus an ok flag. An empty dialect means direct TLS and
// returns (nil, true) — tlsenum will skip the upgrade phase. An unknown
// dialect returns (nil, false) so the caller can record the skip reason.
func upgraderFor(dialect, sni string) (func(net.Conn) error, bool) {
if dialect == "" {
return nil, true
}
up, ok := starttlsUpgraders[dialect]
if !ok {
return nil, false
}
return func(c net.Conn) error { return up(c, sni) }, true
}

View file

@ -52,7 +52,10 @@ func starttlsLDAP(conn net.Conn, sni string) error {
if err != nil {
return fmt.Errorf("read response length: %w", err)
}
if length <= 0 || length > 4096 {
// 16 KiB comfortably accommodates an ExtendedResponse with a verbose
// diagnosticMessage while still bounding memory against a hostile peer.
const maxLDAPResponseBytes = 16 * 1024
if length <= 0 || length > maxLDAPResponseBytes {
return fmt.Errorf("unreasonable LDAP response length %d", length)
}
body := make([]byte, length)

View file

@ -132,6 +132,24 @@ func TestStartTLS_IMAP_OK(t *testing.T) {
}
}
func TestStartTLS_IMAP_Refused(t *testing.T) {
err := runStartTLS(t, starttlsIMAP, "imap.example.com", func(c net.Conn) error {
br := bufio.NewReader(c)
_, _ = io.WriteString(c, "* OK IMAP4rev1 ready\r\n")
_, _ = readLineCRLF(br)
_, _ = io.WriteString(c, "* CAPABILITY IMAP4rev1 STARTTLS\r\nA001 OK CAPABILITY completed\r\n")
_, _ = readLineCRLF(br)
_, err := io.WriteString(c, "A002 NO STARTTLS unavailable\r\n")
return err
})
if err == nil {
t.Fatal("expected refusal error")
}
if errors.Is(err, errStartTLSNotOffered) {
t.Fatalf("refusal should not be classified as not-offered: %v", err)
}
}
func TestStartTLS_IMAP_NotAdvertised(t *testing.T) {
err := runStartTLS(t, starttlsIMAP, "imap.example.com", func(c net.Conn) error {
br := bufio.NewReader(c)
@ -185,6 +203,24 @@ func TestStartTLS_POP3_NotAdvertised(t *testing.T) {
}
}
func TestStartTLS_POP3_Refused(t *testing.T) {
err := runStartTLS(t, starttlsPOP3, "pop.example.com", func(c net.Conn) error {
br := bufio.NewReader(c)
_, _ = io.WriteString(c, "+OK POP3 ready\r\n")
_, _ = readLineCRLF(br)
_, _ = io.WriteString(c, "+OK capa list\r\nUSER\r\nSTLS\r\n.\r\n")
_, _ = readLineCRLF(br)
_, err := io.WriteString(c, "-ERR STLS unavailable\r\n")
return err
})
if err == nil {
t.Fatal("expected refusal error")
}
if errors.Is(err, errStartTLSNotOffered) {
t.Fatalf("refusal should not be classified as not-offered: %v", err)
}
}
func TestStartTLS_XMPP_OK(t *testing.T) {
err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error {
br := bufio.NewReader(c)
@ -225,6 +261,47 @@ func TestStartTLS_XMPP_NotAdvertised(t *testing.T) {
}
}
func TestStartTLS_XMPP_Refused(t *testing.T) {
err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error {
br := bufio.NewReader(c)
buf := make([]byte, 1024)
if _, err := br.Read(buf); err != nil {
return err
}
_, _ = io.WriteString(c,
`<?xml version='1.0'?><stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' id='1' from='xmpp.example.com' version='1.0'>`+
`<stream:features><starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/></stream:features>`)
if _, err := br.Read(buf); err != nil {
return err
}
_, err := io.WriteString(c, `<failure xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>`)
return err
})
if err == nil {
t.Fatal("expected failure error")
}
if errors.Is(err, errStartTLSNotOffered) {
t.Fatalf("<failure/> should not be classified as not-offered: %v", err)
}
}
func TestStartTLS_XMPP_StreamError(t *testing.T) {
err := runStartTLS(t, starttlsXMPPClient, "xmpp.example.com", func(c net.Conn) error {
br := bufio.NewReader(c)
buf := make([]byte, 1024)
if _, err := br.Read(buf); err != nil {
return err
}
_, err := io.WriteString(c,
`<?xml version='1.0'?><stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' id='1' from='xmpp.example.com' version='1.0'>`+
`<stream:error><host-unknown xmlns='urn:ietf:params:xml:ns:xmpp-streams'/></stream:error>`)
return err
})
if err == nil {
t.Fatal("expected stream:error to surface as error")
}
}
func TestStartTLS_LDAP_OK(t *testing.T) {
err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error {
// Drain the StartTLS request (fixed 31 bytes: 0x30 0x1d + 29 bytes).
@ -250,6 +327,86 @@ func TestStartTLS_LDAP_OK(t *testing.T) {
}
}
func TestStartTLS_LDAP_WrongTag(t *testing.T) {
err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error {
req := make([]byte, 31)
if _, err := io.ReadFull(c, req); err != nil {
return err
}
_, err := c.Write([]byte{0x42, 0x00})
return err
})
if err == nil {
t.Fatal("expected error for wrong tag")
}
if errors.Is(err, errStartTLSNotOffered) {
t.Fatalf("malformed response should not be classified as not-offered: %v", err)
}
}
func TestStartTLS_LDAP_OversizedLength(t *testing.T) {
err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error {
req := make([]byte, 31)
if _, err := io.ReadFull(c, req); err != nil {
return err
}
// SEQUENCE with long-form length = 0x10000 (64 KiB) — beyond our 16 KiB cap.
_, err := c.Write([]byte{0x30, 0x83, 0x01, 0x00, 0x00})
return err
})
if err == nil {
t.Fatal("expected oversized-length error")
}
}
func TestStartTLS_LDAP_TruncatedBody(t *testing.T) {
err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error {
req := make([]byte, 31)
if _, err := io.ReadFull(c, req); err != nil {
return err
}
// Announce 12 bytes of body, only send 5 then close.
_, err := c.Write([]byte{0x30, 0x0c, 0x02, 0x01, 0x01, 0x78, 0x07})
return err
})
if err == nil {
t.Fatal("expected error on truncated body")
}
}
func TestStartTLS_LDAP_DiagnosticMessageOver4KiB(t *testing.T) {
// A real-world response with a verbose diagnosticMessage can exceed the
// previous 4 KiB cap. Confirm the bumped 16 KiB cap accepts it.
const diagLen = 8000
diag := make([]byte, diagLen)
for i := range diag {
diag[i] = 'x'
}
err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error {
req := make([]byte, 31)
if _, err := io.ReadFull(c, req); err != nil {
return err
}
// Body: messageID(3) + extResp tag(1) + extResp len(3) + resultCode(3) + matchedDN(2) + diag tag+long-len(4) + diag bytes
// extResp inner length = resultCode(3) + matchedDN(2) + diagTLV(4+diagLen) = 9 + diagLen
extInner := 9 + diagLen
// Outer SEQUENCE inner length = messageID(3) + extResp TLV(1+3+extInner)
outerInner := 3 + 4 + extInner
buf := []byte{0x30, 0x82, byte(outerInner >> 8), byte(outerInner & 0xff)}
buf = append(buf, 0x02, 0x01, 0x01) // messageID
buf = append(buf, 0x78, 0x82, byte(extInner>>8), byte(extInner&0xff))
buf = append(buf, 0x0a, 0x01, 0x00) // resultCode = success
buf = append(buf, 0x04, 0x00) // matchedDN ""
buf = append(buf, 0x04, 0x82, byte(diagLen>>8), byte(diagLen&0xff))
buf = append(buf, diag...)
_, err := c.Write(buf)
return err
})
if err != nil {
t.Fatalf("expected success with verbose diagnosticMessage, got: %v", err)
}
}
func TestStartTLS_LDAP_Refused(t *testing.T) {
err := runStartTLS(t, starttlsLDAP, "ldap.example.com", func(c net.Conn) error {
req := make([]byte, 31)

View file

@ -22,13 +22,20 @@ func starttlsXMPPServer(conn net.Conn, sni string) error {
return starttlsXMPP(conn, sni, "jabber:server")
}
// xmppPreTLSReadLimit caps the bytes the XML decoder may pull from an
// untrusted peer before the TLS handshake. The legitimate pre-TLS exchange
// (<stream:stream> opening + <stream:features> + <proceed/>) is well under
// 1 KiB; 64 KiB is generous for non-malicious servers while bounding memory
// against a peer that streams unbounded XML to exhaust the prober.
const xmppPreTLSReadLimit = 64 * 1024
func starttlsXMPP(conn net.Conn, sni, ns string) error {
header := fmt.Sprintf(`<?xml version='1.0'?><stream:stream xmlns='%s' xmlns:stream='http://etherx.jabber.org/streams' version='1.0' to='%s'>`, ns, sni)
if _, err := io.WriteString(conn, header); err != nil {
return fmt.Errorf("write stream header: %w", err)
}
dec := xml.NewDecoder(conn)
dec := xml.NewDecoder(&io.LimitedReader{R: conn, N: xmppPreTLSReadLimit})
// Read the inbound <stream:stream> opening and its <stream:features>.
// A peer that opens with <stream:error/> (or anything other than features)

View file

@ -9,8 +9,9 @@ const ObservationKeyTLSProbes = "tls_probes"
// Option ids on CheckerOptions.
const (
OptionEndpoints = "endpoints"
OptionProbeTimeoutMs = "probeTimeoutMs"
OptionEndpoints = "endpoints"
OptionProbeTimeoutMs = "probeTimeoutMs"
OptionEnumerateCiphers = "enumerateCiphers"
)
// Defaults shared between the definition's Default field and the runtime
@ -100,6 +101,12 @@ type TLSProbe struct {
Chain []CertInfo `json:"chain,omitempty"`
ElapsedMS int64 `json:"elapsed_ms,omitempty"`
// Enum carries the protocol-version and cipher-suite sweep. It is only
// populated when the user enables OptionEnumerateCiphers. Direct TLS and
// supported STARTTLS dialects are both swept; a STARTTLS endpoint with
// an unknown dialect is skipped with a reason recorded in Enum.Skipped.
Enum *TLSEnumeration `json:"enum,omitempty"`
// Error is a compatibility summary of whichever raw error applies.
// Left for any external consumer still inspecting it; rules should
// look at TCPError / HandshakeError instead.
@ -142,3 +149,31 @@ type CertInfo struct {
const (
ExpiringSoonThreshold = 14 * 24 * time.Hour
)
// TLSEnumeration is the result of sweeping a (version × cipher) matrix
// against an endpoint. The exact set the server accepts (rather than just the
// one combination it negotiated under default Go preferences) lets rules flag
// legacy versions and weak cipher suites that would otherwise stay invisible.
type TLSEnumeration struct {
// Versions lists every protocol version for which at least one cipher
// was accepted, with the matching cipher suites.
Versions []EnumVersion `json:"versions,omitempty"`
// Skipped is set when enumeration was not attempted (e.g. STARTTLS
// endpoint, prior handshake failure). Empty when enumeration ran.
Skipped string `json:"skipped,omitempty"`
// DurationMS is the wall-clock time spent enumerating, for ops visibility.
DurationMS int64 `json:"duration_ms,omitempty"`
}
// EnumVersion is one accepted protocol version plus the ciphers it accepted.
type EnumVersion struct {
Version uint16 `json:"version"`
Name string `json:"name"`
Ciphers []EnumCipher `json:"ciphers,omitempty"`
}
// EnumCipher is one accepted cipher suite.
type EnumCipher struct {
ID uint16 `json:"id"`
Name string `json:"name"`
}

View file

@ -0,0 +1,91 @@
package checker
import (
"errors"
"net"
"testing"
)
// TestUpgraderFor_DirectTLS verifies that an empty dialect returns a nil
// upgrader with ok=true: tlsenum's contract is that nil means "no upgrade
// phase", so direct-TLS endpoints must round-trip through this branch
// without producing a shim that would call into the registry.
func TestUpgraderFor_DirectTLS(t *testing.T) {
up, ok := upgraderFor("", "example.test")
if !ok {
t.Fatalf("expected ok=true for empty dialect")
}
if up != nil {
t.Fatalf("expected nil upgrader for empty dialect, got %T", up)
}
}
func TestUpgraderFor_UnknownDialect(t *testing.T) {
up, ok := upgraderFor("totally-not-a-dialect", "example.test")
if ok {
t.Fatalf("expected ok=false for unknown dialect")
}
if up != nil {
t.Fatalf("expected nil upgrader for unknown dialect, got %T", up)
}
}
// TestUpgraderFor_KnownDialect_ForwardsSNI registers a temporary fake dialect
// in the registry, asks upgraderFor for its callback, invokes the callback,
// and asserts the registered upgrader received the expected SNI. We can't
// reuse a real dialect for this because they all read/write protocol-specific
// banners on the connection — the point of this test is the SNI plumbing in
// the closure, not the dialect's own behavior.
func TestUpgraderFor_KnownDialect_ForwardsSNI(t *testing.T) {
const dialect = "test-fake"
const wantSNI = "host.example.test"
var (
gotSNI string
gotConn net.Conn
)
wantErr := errors.New("sentinel from fake upgrader")
registerStartTLS(dialect, func(c net.Conn, sni string) error {
gotConn = c
gotSNI = sni
return wantErr
})
defer delete(starttlsUpgraders, dialect)
up, ok := upgraderFor(dialect, wantSNI)
if !ok || up == nil {
t.Fatalf("expected non-nil upgrader and ok=true, got nil=%v ok=%v", up == nil, ok)
}
// Use a closed pipe end as a sentinel net.Conn — the registered upgrader
// captures it without doing I/O, so a real connection is unnecessary.
a, b := net.Pipe()
_ = a.Close()
_ = b.Close()
if err := up(a); !errors.Is(err, wantErr) {
t.Fatalf("expected sentinel error to propagate, got %v", err)
}
if gotSNI != wantSNI {
t.Fatalf("registered upgrader received SNI %q, want %q", gotSNI, wantSNI)
}
if gotConn != a {
t.Fatalf("registered upgrader received a different conn than the one passed in")
}
}
// TestUpgraderFor_RealDialects_AllRegistered guards against silently dropping
// a dialect from the registry: every protocol referenced by the contract's
// STARTTLS values must resolve to a non-nil upgrader. The list mirrors the
// dialects implemented in starttls_*.go.
func TestUpgraderFor_RealDialects_AllRegistered(t *testing.T) {
dialects := []string{"smtp", "submission", "imap", "pop3", "xmpp-client", "xmpp-server", "ldap"}
for _, d := range dialects {
t.Run(d, func(t *testing.T) {
up, ok := upgraderFor(d, "host.example")
if !ok || up == nil {
t.Fatalf("dialect %q is not registered", d)
}
})
}
}