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__WITH__ // 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 }