Initial commit

This commit is contained in:
nemunaire 2026-04-23 12:17:44 +07:00
commit 06036c89d9
29 changed files with 4891 additions and 0 deletions

285
checker/algorithms.go Normal file
View file

@ -0,0 +1,285 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"fmt"
"strings"
)
// The tables below encode the safety verdict for each common SSH
// algorithm name. They are a condensed, hand-curated view of the
// ssh-audit algorithm database
// (https://github.com/jtesta/ssh-audit/blob/master/src/ssh_audit/ssh2_kexdb.py)
// reduced to the severities this checker surfaces.
//
// The logic we apply is: an algorithm advertised by the server is OK
// if it is in safeAlgos, suspicious (warn) if in weakAlgos, and
// critical if in brokenAlgos. Anything unknown is silently passed
// through: SSH is extensible, we prefer false negatives to noise.
type algoVerdict struct {
severity string // "crit", "warn", "info"
reason string // short human-readable reason
}
// KEX (key exchange) algorithms.
var kexAlgos = map[string]algoVerdict{
"curve25519-sha256": {},
"curve25519-sha256@libssh.org": {},
"sntrup761x25519-sha512@openssh.com": {severity: SeverityOK, reason: "hybrid post-quantum"},
"mlkem768x25519-sha256": {severity: SeverityOK, reason: "hybrid post-quantum (ML-KEM)"},
"ecdh-sha2-nistp256": {},
"ecdh-sha2-nistp384": {},
"ecdh-sha2-nistp521": {},
"diffie-hellman-group-exchange-sha256": {},
"diffie-hellman-group14-sha256": {},
"diffie-hellman-group16-sha512": {},
"diffie-hellman-group18-sha512": {},
"kex-strict-s-v00@openssh.com": {}, // not a real KEX, advertises "strict-kex" per CVE-2023-48795
// Deprecated / suspicious.
"diffie-hellman-group14-sha1": {severity: SeverityWarn, reason: "SHA-1 hash; upgrade to -sha256 variant"},
"diffie-hellman-group-exchange-sha1": {severity: SeverityWarn, reason: "SHA-1 hash; group-exchange with SHA-1 is discouraged"},
"rsa1024-sha1": {severity: SeverityCrit, reason: "1024-bit RSA KEX with SHA-1"},
"rsa2048-sha256": {severity: SeverityWarn, reason: "RSA key transport is deprecated"},
// Broken.
"diffie-hellman-group1-sha1": {severity: SeverityCrit, reason: "weak 1024-bit MODP group; vulnerable to Logjam"},
"gss-group1-sha1-toWM5Slw5Ew8Mqkay+al2g==": {severity: SeverityCrit, reason: "weak 1024-bit MODP group"},
}
// Server host-key algorithms.
var hostKeyAlgos = map[string]algoVerdict{
"ssh-ed25519": {},
"ssh-ed25519-cert-v01@openssh.com": {},
"ecdsa-sha2-nistp256": {},
"ecdsa-sha2-nistp384": {},
"ecdsa-sha2-nistp521": {},
"rsa-sha2-512": {},
"rsa-sha2-256": {},
"ssh-rsa": {severity: SeverityWarn, reason: "RSA with SHA-1 signatures (RFC 8332 marks as deprecated)"},
"ssh-dss": {severity: SeverityCrit, reason: "DSA is obsolete (weak 1024-bit signatures)"},
"ssh-rsa-cert-v01@openssh.com": {severity: SeverityWarn, reason: "RSA/SHA-1 certificate signatures are deprecated"},
"ssh-dss-cert-v01@openssh.com": {severity: SeverityCrit, reason: "DSA certificate signatures are obsolete"},
}
// Symmetric encryption algorithms (ciphers).
var cipherAlgos = map[string]algoVerdict{
"chacha20-poly1305@openssh.com": {},
"aes256-gcm@openssh.com": {},
"aes128-gcm@openssh.com": {},
"aes256-ctr": {},
"aes192-ctr": {},
"aes128-ctr": {},
"aes256-cbc": {severity: SeverityWarn, reason: "CBC mode is vulnerable to oracle attacks; prefer CTR or GCM"},
"aes192-cbc": {severity: SeverityWarn, reason: "CBC mode; prefer CTR or GCM"},
"aes128-cbc": {severity: SeverityWarn, reason: "CBC mode; prefer CTR or GCM"},
"rijndael-cbc@lysator.liu.se": {severity: SeverityWarn, reason: "legacy AES-CBC alias"},
"3des-cbc": {severity: SeverityCrit, reason: "Triple-DES is obsolete (Sweet32 birthday attack)"},
"blowfish-cbc": {severity: SeverityCrit, reason: "Blowfish-CBC; 64-bit block size (Sweet32)"},
"cast128-cbc": {severity: SeverityCrit, reason: "64-bit block; Sweet32"},
"arcfour": {severity: SeverityCrit, reason: "RC4 is broken"},
"arcfour128": {severity: SeverityCrit, reason: "RC4 is broken"},
"arcfour256": {severity: SeverityCrit, reason: "RC4 is broken"},
"none": {severity: SeverityCrit, reason: "No encryption"},
"des-cbc@ssh.com": {severity: SeverityCrit, reason: "DES is broken"},
}
// MAC algorithms.
var macAlgos = map[string]algoVerdict{
"hmac-sha2-512-etm@openssh.com": {},
"hmac-sha2-256-etm@openssh.com": {},
"umac-128-etm@openssh.com": {},
"hmac-sha2-512": {severity: SeverityWarn, reason: "non-ETM MAC; prefer -etm@openssh.com variants"},
"hmac-sha2-256": {severity: SeverityWarn, reason: "non-ETM MAC; prefer -etm@openssh.com variants"},
"umac-128@openssh.com": {severity: SeverityWarn, reason: "non-ETM MAC; prefer umac-128-etm@openssh.com"},
"umac-64@openssh.com": {severity: SeverityWarn, reason: "64-bit tag; prefer umac-128-etm@openssh.com"},
"hmac-sha1": {severity: SeverityWarn, reason: "SHA-1 MAC; prefer SHA-2 ETM"},
"hmac-sha1-etm@openssh.com": {severity: SeverityWarn, reason: "SHA-1 MAC (ETM); prefer SHA-2 ETM"},
"hmac-sha1-96": {severity: SeverityCrit, reason: "truncated SHA-1 MAC; forbidden"},
"hmac-md5": {severity: SeverityCrit, reason: "MD5 MAC; broken"},
"hmac-md5-96": {severity: SeverityCrit, reason: "truncated MD5 MAC; broken"},
"hmac-ripemd160": {severity: SeverityWarn, reason: "RIPEMD-160 MAC; seldom used"},
"none": {severity: SeverityCrit, reason: "No MAC"},
}
// Unknown names produce an empty verdict (no severity); callers treat that as "don't report"
// to avoid noise from SSH extensions we can't classify.
func verdictFor(table map[string]algoVerdict, name string) algoVerdict {
if v, ok := table[name]; ok {
return v
}
return algoVerdict{}
}
// analyseWeakAlgos emits one Issue per weak/broken algorithm in values
// using the verdict table.
func analyseWeakAlgos(addr, family string, values []string, table map[string]algoVerdict) []Issue {
var issues []Issue
for _, a := range values {
v := verdictFor(table, a)
if v.severity == "" || v.severity == SeverityOK {
continue
}
issues = append(issues, Issue{
Code: fmt.Sprintf("weak_%s", family),
Severity: v.severity,
Message: fmt.Sprintf("%s algorithm %q is %s", family, a, v.reason),
Fix: fixForFamily(family),
Endpoint: addr,
})
}
return issues
}
// analyseStrictKex flags the absence of the Terrapin mitigation marker
// (CVE-2023-48795): any modern sshd advertises kex-strict-s-v00@openssh.com
// alongside its KEX algorithms when the patched transport is available.
func analyseStrictKex(addr string, kex []string) []Issue {
if len(kex) == 0 || contains(kex, "kex-strict-s-v00@openssh.com") {
return nil
}
return []Issue{{
Code: "missing_strict_kex",
Severity: SeverityWarn,
Message: "Server does not advertise strict-KEX (CVE-2023-48795 \"Terrapin\"). Upgrade OpenSSH to 9.6 or later.",
Fix: "Upgrade sshd; no client-side fix mitigates this server-side gap.",
Endpoint: addr,
}}
}
// analysePreauthCompression flags servers that offer pre-authentication
// zlib compression. Many setups use zlib@openssh.com safely post-auth.
func analysePreauthCompression(addr string, comp []string) []Issue {
for _, c := range comp {
if c == "zlib" {
return []Issue{{
Code: "preauth_compression",
Severity: SeverityInfo,
Message: "Server offers pre-authentication zlib compression. Prefer zlib@openssh.com which kicks in only after auth.",
Endpoint: addr,
}}
}
}
return nil
}
// analyseAlgorithms is a convenience used by the HTML report: returns
// every algorithm-related issue for a single endpoint.
func analyseAlgorithms(addr string, p *SSHProbe) []Issue {
var issues []Issue
issues = append(issues, analyseWeakAlgos(addr, "kex", p.KEX, kexAlgos)...)
issues = append(issues, analyseWeakAlgos(addr, "hostkey_alg", p.HostKey, hostKeyAlgos)...)
issues = append(issues, analyseWeakAlgos(addr, "cipher", uniqueMerge(p.CiphersC2S, p.CiphersS2C), cipherAlgos)...)
issues = append(issues, analyseWeakAlgos(addr, "mac", uniqueMerge(p.MACsC2S, p.MACsS2C), macAlgos)...)
issues = append(issues, analyseStrictKex(addr, p.KEX)...)
issues = append(issues, analysePreauthCompression(addr, p.CompC2S)...)
return issues
}
// fixForFamily returns a short generic hint to pair with the
// per-algorithm warning. The HTML report shows algorithm-specific
// verdicts alongside this so operators know what to edit.
func fixForFamily(family string) string {
switch family {
case "kex":
return "Edit /etc/ssh/sshd_config KexAlgorithms= to list only modern algorithms (curve25519-sha256, ecdh-sha2-nistp256, diffie-hellman-group16-sha512)."
case "hostkey_alg":
return "Set HostKeyAlgorithms= to ssh-ed25519,rsa-sha2-512,rsa-sha2-256 (drop ssh-rsa and ssh-dss)."
case "cipher":
return "Restrict Ciphers= to chacha20-poly1305@openssh.com, aes256-gcm@openssh.com, aes128-gcm@openssh.com and the -ctr variants."
case "mac":
return "Restrict MACs= to the -etm@openssh.com variants (hmac-sha2-256-etm, hmac-sha2-512-etm, umac-128-etm)."
}
return ""
}
// uniqueMerge returns the union of a and b, preserving first-seen order.
func uniqueMerge(a, b []string) []string {
seen := map[string]bool{}
var out []string
for _, v := range a {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for _, v := range b {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func contains(haystack []string, needle string) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
// analyseAuthMethods flags the classic "password auth exposed to the
// internet" antipattern and complementary findings.
func analyseAuthMethods(addr string, p *SSHProbe) []Issue {
var issues []Issue
if p.AuthMethods == nil {
return nil
}
if p.PasswordAuth {
issues = append(issues, Issue{
Code: "password_auth_enabled",
Severity: SeverityWarn,
Message: "Server accepts password authentication. Combined with publicly exposed sshd this is the single largest source of compromises.",
Fix: "Set PasswordAuthentication no in /etc/ssh/sshd_config and rely on publickey (or keyboard-interactive + hardware MFA).",
Endpoint: addr,
})
}
if !p.PublicKeyAuth && len(p.AuthMethods) > 0 {
issues = append(issues, Issue{
Code: "no_publickey_auth",
Severity: SeverityWarn,
Message: "Server does not advertise public-key authentication. This is unusual for production SSH deployments.",
Fix: "Set PubkeyAuthentication yes in sshd_config.",
Endpoint: addr,
})
}
return issues
}
// lowerAll returns a copy of s with every element lowercased. Used by
// callers that want a case-insensitive membership check.
func lowerAll(s []string) []string {
out := make([]string, len(s))
for i, v := range s {
out[i] = strings.ToLower(v)
}
return out
}

218
checker/collect.go Normal file
View file

@ -0,0 +1,218 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"encoding/json"
"fmt"
"log"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
happydns "git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
)
// Collect resolves addresses + SSHFP records from the abstract.Server
// service attached to this check, probes every (address, port)
// combination in parallel, and returns a populated SSHData.
func (p *sshProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
server, err := resolveServer(opts)
if err != nil {
return nil, err
}
timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs)
if timeoutMs <= 0 {
timeoutMs = DefaultProbeTimeoutMs
}
timeout := time.Duration(timeoutMs) * time.Millisecond
includeAuthProbe := sdk.GetBoolOption(opts, OptionIncludeAuthProbe, true)
ports := parsePorts(optString(opts, OptionPorts, ""))
// Port 22 is always probed.
if !containsUint16(ports, DefaultSSHPort) {
ports = append([]uint16{DefaultSSHPort}, ports...)
}
host, ips := addressesFromServer(server)
if len(ips) == 0 {
return nil, fmt.Errorf("abstract.Server service has no A/AAAA records")
}
sshfp := sshfpFromServer(server)
data := &SSHData{
Domain: host,
SSHFP: sshfp,
CollectedAt: time.Now(),
}
// The fanout is small in practice (at most a handful of IPs × a
// handful of ports), but we still cap concurrency for consistency
// with the TLS checker.
var mu sync.Mutex
var wg sync.WaitGroup
sem := make(chan struct{}, MaxConcurrentProbes)
for _, ip := range ips {
for _, port := range ports {
wg.Add(1)
sem <- struct{}{}
go func(ip string, port uint16) {
defer wg.Done()
defer func() { <-sem }()
probe := probeEndpoint(ctx, host, ip, port, timeout, includeAuthProbe, sshfp)
log.Printf("checker-ssh: %s:%d banner=%q kex=%d hostkeys=%d stage=%s",
ip, port, probe.Banner, len(probe.KEX), len(probe.HostKeys), probe.Stage)
mu.Lock()
data.Endpoints = append(data.Endpoints, probe)
mu.Unlock()
}(ip, port)
}
}
wg.Wait()
return data, nil
}
// resolveServer extracts the *abstract.Server payload from the options.
// Two shapes are supported, same as the ping checker:
// - "service": ServiceMessage (in-process plugin path, or HTTP after
// sdk.GetOption JSON-round-trips).
func resolveServer(opts sdk.CheckerOptions) (*abstract.Server, error) {
svc, ok := sdk.GetOption[happydns.ServiceMessage](opts, OptionService)
if !ok {
return nil, fmt.Errorf("no service in options: did the host wire AutoFillService?")
}
if svc.Type != "abstract.Server" {
return nil, fmt.Errorf("service is %q, expected abstract.Server", svc.Type)
}
var server abstract.Server
if err := json.Unmarshal(svc.Service, &server); err != nil {
return nil, fmt.Errorf("unmarshal abstract.Server: %w", err)
}
return &server, nil
}
// addressesFromServer returns the service's owner domain name (used
// for SNI-like purposes in SSH banner/hostname exchange) and the list
// of IPs to probe.
func addressesFromServer(server *abstract.Server) (host string, ips []string) {
// We can't know the service's owner domain from the Server payload
// alone. The host value we use here is purely informational for
// the report; the ssh handshake itself doesn't need it.
if server.A != nil && len(server.A.A) > 0 {
host = strings.TrimSuffix(server.A.Hdr.Name, ".")
ips = append(ips, server.A.A.String())
}
if server.AAAA != nil && len(server.AAAA.AAAA) > 0 {
if host == "" {
host = strings.TrimSuffix(server.AAAA.Hdr.Name, ".")
}
ips = append(ips, server.AAAA.AAAA.String())
}
return
}
// sshfpFromServer flattens the SSHFP records attached to the service
// into our transport-neutral SSHFPSummary.
func sshfpFromServer(server *abstract.Server) SSHFPSummary {
out := SSHFPSummary{Present: len(server.SSHFP) > 0}
for _, rr := range server.SSHFP {
if rr == nil {
continue
}
out.Records = append(out.Records, SSHFPRecord{
Algorithm: rr.Algorithm,
Type: rr.Type,
Fingerprint: strings.ToLower(rr.FingerPrint),
})
}
return out
}
// Invalid port entries are silently discarded to avoid failing on a bad user input.
func parsePorts(raw string) []uint16 {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
var out []uint16
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
n, err := strconv.Atoi(p)
if err != nil || n <= 0 || n > 65535 {
continue
}
u := uint16(n)
if containsUint16(out, u) {
continue
}
out = append(out, u)
}
return out
}
func containsUint16(list []uint16, v uint16) bool {
for _, x := range list {
if x == v {
return true
}
}
return false
}
// optString returns a string option, tolerating json.Number / float64
// sneaking in for what should have been a bare string.
func optString(opts sdk.CheckerOptions, key, def string) string {
v, ok := opts[key]
if !ok {
return def
}
switch s := v.(type) {
case string:
return s
case fmt.Stringer:
return s.String()
}
return def
}
// Used to make golint happy about unused miekg/dns import if we ever
// stop using the abstract.Server.SSHFP path. Currently the import is
// effectively required transitively; kept as a guard.
var _ = dns.TypeSSHFP
// Used to make golint happy about unused net import if we ever stop
// touching IP parsing here.
var _ = net.IPv4len

88
checker/definition.go Normal file
View file

@ -0,0 +1,88 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
// Defaults to "built-in"; standalone binaries and plugin builds override
// it via -ldflags "-X .../checker.Version=...".
var Version = "built-in"
// Definition returns the CheckerDefinition for the SSH checker.
func (p *sshProvider) Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "ssh",
Name: "SSH",
Version: Version,
Availability: sdk.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.Server"},
},
ObservationKeys: []sdk.ObservationKey{ObservationKeySSH},
Options: sdk.CheckerOptionsDocumentation{
UserOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionPorts,
Type: "string",
Label: "Additional ports",
Placeholder: "22, 2222",
Description: "Comma-separated list of additional TCP ports to probe. Port 22 is always probed.",
Default: "",
},
{
Id: OptionProbeTimeoutMs,
Type: "number",
Label: "Per-endpoint probe timeout (ms)",
Description: "Maximum time allowed for dial + banner + KEXINIT + handshake on a single endpoint.",
Default: float64(DefaultProbeTimeoutMs),
},
{
Id: OptionIncludeAuthProbe,
Type: "bool",
Label: "Enumerate authentication methods",
Description: "Perform a second connection with a dummy user to discover which auth methods the server advertises. Harmless but adds a connection attempt per endpoint.",
Default: true,
},
},
ServiceOpts: []sdk.CheckerOptionDocumentation{
{
Id: OptionService,
Label: "Service",
AutoFill: sdk.AutoFillService,
Hide: true,
},
},
},
Rules: Rules(),
Interval: &sdk.CheckIntervalSpec{
Min: 6 * time.Hour,
Max: 7 * 24 * time.Hour,
Default: 24 * time.Hour,
},
HasHTMLReport: true,
}
}

246
checker/interactive.go Normal file
View file

@ -0,0 +1,246 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build standalone
package checker
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
"github.com/miekg/dns"
sdk "git.happydns.org/checker-sdk-go/checker"
happydns "git.happydns.org/happyDomain/model"
"git.happydns.org/happyDomain/services/abstract"
)
// RenderForm implements server.Interactive: the human-facing form
// exposed at GET /check when the checker runs as a standalone binary.
func (p *sshProvider) RenderForm() []sdk.CheckerOptionField {
return []sdk.CheckerOptionField{
{
Id: "domain",
Type: "string",
Label: "Host name",
Placeholder: "ssh.example.com",
Required: true,
Description: "The SSH server hostname to probe. A/AAAA and SSHFP records are looked up live.",
},
{
Id: OptionPorts,
Type: "string",
Label: "Additional ports",
Placeholder: "22, 2222",
Description: "Comma-separated list of additional TCP ports to probe. Port 22 is always probed.",
},
{
Id: OptionProbeTimeoutMs,
Type: "number",
Label: "Per-endpoint probe timeout (ms)",
Default: float64(DefaultProbeTimeoutMs),
},
{
Id: OptionIncludeAuthProbe,
Type: "bool",
Label: "Enumerate authentication methods",
Default: true,
},
}
}
// ParseForm implements server.Interactive: resolves the submitted
// hostname into an abstract.Server payload and wraps it in the
// ServiceMessage shape that Collect expects.
func (p *sshProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
domain := strings.TrimSpace(r.FormValue("domain"))
domain = strings.TrimSuffix(domain, ".")
if domain == "" {
return nil, errors.New("host name is required")
}
fqdn := dns.Fqdn(domain)
resolver, err := systemResolver()
if err != nil {
return nil, fmt.Errorf("resolver: %w", err)
}
server := &abstract.Server{}
if a, err := lookupA(resolver, fqdn); err != nil {
return nil, fmt.Errorf("A lookup for %s: %w", domain, err)
} else if a != nil {
server.A = a
}
if aaaa, err := lookupAAAA(resolver, fqdn); err != nil {
return nil, fmt.Errorf("AAAA lookup for %s: %w", domain, err)
} else if aaaa != nil {
server.AAAA = aaaa
}
if server.A == nil && server.AAAA == nil {
return nil, fmt.Errorf("no A/AAAA records found for %s", domain)
}
if sshfp, err := lookupSSHFP(resolver, fqdn); err != nil {
return nil, fmt.Errorf("SSHFP lookup for %s: %w", domain, err)
} else {
server.SSHFP = sshfp
}
svcBody, err := json.Marshal(server)
if err != nil {
return nil, fmt.Errorf("marshal abstract.Server: %w", err)
}
opts := sdk.CheckerOptions{
OptionService: happydns.ServiceMessage{
ServiceMeta: happydns.ServiceMeta{
Type: "abstract.Server",
Domain: domain,
},
Service: svcBody,
},
}
if ports := strings.TrimSpace(r.FormValue(OptionPorts)); ports != "" {
opts[OptionPorts] = ports
}
if raw := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); raw != "" {
v, err := strconv.ParseFloat(raw, 64)
if err != nil {
return nil, errors.New("timeout must be a number")
}
opts[OptionProbeTimeoutMs] = v
}
opts[OptionIncludeAuthProbe] = parseInteractiveBool(r, OptionIncludeAuthProbe, true)
return opts, nil
}
// parseInteractiveBool reads a checkbox-style field. HTML forms omit
// unchecked checkboxes entirely, so a missing key means false if the
// form was submitted (detected via the required "domain" field).
func parseInteractiveBool(r *http.Request, key string, def bool) bool {
if _, ok := r.Form[key]; !ok {
if _, submitted := r.Form["domain"]; submitted {
return false
}
return def
}
v := strings.ToLower(strings.TrimSpace(r.FormValue(key)))
switch v {
case "", "0", "false", "off", "no":
return false
default:
return true
}
}
// systemResolver picks a DNS server to send explicit SSHFP/A/AAAA
// queries to. Resolution order:
// 1. CHECKER_DNS_RESOLVER env var (host or host:port)
// 2. The OS resolver config when one exists (resolvConfPath)
// 3. 1.1.1.1:53 as a last-resort public fallback
func systemResolver() (string, error) {
if env := strings.TrimSpace(os.Getenv("CHECKER_DNS_RESOLVER")); env != "" {
if _, _, err := net.SplitHostPort(env); err != nil {
env = net.JoinHostPort(env, "53")
}
return env, nil
}
if path := resolvConfPath(); path != "" {
if cfg, err := dns.ClientConfigFromFile(path); err == nil && len(cfg.Servers) > 0 {
return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil
}
}
return net.JoinHostPort("1.1.1.1", "53"), nil
}
// resolvConfPath returns the platform-specific resolver config file, or
// "" if none is expected on this OS (e.g. Windows).
func resolvConfPath() string {
for _, p := range []string{"/etc/resolv.conf"} {
if _, err := os.Stat(p); err == nil {
return p
}
}
return ""
}
func dnsExchange(resolver, name string, qtype uint16) (*dns.Msg, error) {
msg := new(dns.Msg)
msg.SetQuestion(name, qtype)
msg.RecursionDesired = true
c := new(dns.Client)
in, _, err := c.Exchange(msg, resolver)
if err != nil {
return nil, err
}
if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError {
return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
}
return in, nil
}
func lookupA(resolver, fqdn string) (*dns.A, error) {
in, err := dnsExchange(resolver, fqdn, dns.TypeA)
if err != nil {
return nil, err
}
for _, rr := range in.Answer {
if a, ok := rr.(*dns.A); ok {
return a, nil
}
}
return nil, nil
}
func lookupAAAA(resolver, fqdn string) (*dns.AAAA, error) {
in, err := dnsExchange(resolver, fqdn, dns.TypeAAAA)
if err != nil {
return nil, err
}
for _, rr := range in.Answer {
if aaaa, ok := rr.(*dns.AAAA); ok {
return aaaa, nil
}
}
return nil, nil
}
func lookupSSHFP(resolver, fqdn string) ([]*dns.SSHFP, error) {
in, err := dnsExchange(resolver, fqdn, dns.TypeSSHFP)
if err != nil {
return nil, err
}
var out []*dns.SSHFP
for _, rr := range in.Answer {
if s, ok := rr.(*dns.SSHFP); ok {
out = append(out, s)
}
}
return out, nil
}

378
checker/kexinit.go Normal file
View file

@ -0,0 +1,378 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"bufio"
"crypto/rand"
"encoding/binary"
"fmt"
"io"
"strings"
)
// The SSH transport protocol is fully specified in RFC 4253. For the
// security audit we only need the pre-authentication handshake:
//
// 1. exchange of protocol version strings (ASCII banners, CRLF-terminated)
// 2. exchange of SSH_MSG_KEXINIT packets, which carry the full algorithm
// preference lists in one go.
//
// We deliberately avoid going beyond KEXINIT: a shallow probe is cheap,
// works against every RFC-compliant SSH server without depending on any
// particular algorithm family being supported, and sidesteps the risk of
// running actual KEX math with untrusted peers.
const (
// sshClientBanner is the version string we advertise. ssh-audit and
// nmap publish a recognisable banner so operators can tell an audit
// probe apart from a real client in their logs.
sshClientBanner = "SSH-2.0-happyDomain-checker_1.0"
msgKexInit = 20
// maxPacketSize caps the largest packet we will read. RFC 4253 allows
// up to 32768 bytes of payload, so 65k is a safe ceiling that also
// protects us from a rogue server trying to exhaust memory.
maxPacketSize = 65535
// maxBannerSize limits how much we read before we give up on the
// peer's version string. RFC 4253 mandates at most 255 bytes.
maxBannerSize = 4096
)
// kexInitPayload is the parsed contents of a KEXINIT packet. The field
// names match RFC 4253 §7.1 verbatim to make audits easy.
type kexInitPayload struct {
Cookie [16]byte
KexAlgorithms []string
ServerHostKeyAlgorithms []string
EncryptionAlgorithmsClientToSvr []string
EncryptionAlgorithmsSvrToClient []string
MACAlgorithmsClientToSvr []string
MACAlgorithmsSvrToClient []string
CompressionAlgorithmsClientToSv []string
CompressionAlgorithmsSvrToClt []string
LanguagesClientToSvr []string
LanguagesSvrToClient []string
FirstKexPacketFollows bool
}
// readBanner reads the peer's SSH identification string. Servers may
// send several CRLF-terminated lines of free-text before the actual
// "SSH-2.0-..." line (RFC 4253 §4.2); we skip those and return the first
// line that looks like a version exchange.
func readBanner(r *bufio.Reader) (string, error) {
for i := 0; i < 16; i++ {
line, err := readLine(r, maxBannerSize)
if err != nil {
return "", err
}
if strings.HasPrefix(line, "SSH-") {
return line, nil
}
}
return "", fmt.Errorf("no SSH version string received")
}
// readLine reads a single CRLF-terminated line (or LF-terminated, as
// some servers omit the CR) and returns it without the terminator.
func readLine(r *bufio.Reader, max int) (string, error) {
var buf []byte
for {
b, err := r.ReadByte()
if err != nil {
return "", err
}
if b == '\n' {
if n := len(buf); n > 0 && buf[n-1] == '\r' {
buf = buf[:n-1]
}
return string(buf), nil
}
buf = append(buf, b)
if len(buf) > max {
return "", fmt.Errorf("line too long")
}
}
}
// writeBanner sends our client identification string.
func writeBanner(w io.Writer) error {
_, err := io.WriteString(w, sshClientBanner+"\r\n")
return err
}
// readPacket reads a single SSH binary packet (RFC 4253 §6) from r. The
// handshake is still in cleartext at this point, so we don't worry
// about MAC or cipher state: packet_length + padding_length + payload +
// random padding, no MAC.
func readPacket(r io.Reader) (payload []byte, err error) {
var lenBuf [4]byte
if _, err = io.ReadFull(r, lenBuf[:]); err != nil {
return nil, err
}
packetLen := binary.BigEndian.Uint32(lenBuf[:])
if packetLen < 5 || packetLen > maxPacketSize {
return nil, fmt.Errorf("invalid packet length %d", packetLen)
}
body := make([]byte, packetLen)
if _, err = io.ReadFull(r, body); err != nil {
return nil, err
}
padLen := int(body[0])
if padLen >= len(body) {
return nil, fmt.Errorf("invalid padding length %d vs packet %d", padLen, len(body))
}
return body[1 : len(body)-padLen], nil
}
// writePacket frames payload into an RFC 4253 binary packet and sends it.
func writePacket(w io.Writer, payload []byte) error {
// packet_length covers padding_length + payload + random_padding,
// but not itself. The total (4 + packet_length) must be a multiple
// of 8 (the block size used in unencrypted mode), and padding must
// be at least 4 bytes.
const blockSize = 8
padLen := blockSize - ((5 + len(payload)) % blockSize)
if padLen < 4 {
padLen += blockSize
}
packetLen := 1 + len(payload) + padLen
buf := make([]byte, 4+packetLen)
binary.BigEndian.PutUint32(buf[:4], uint32(packetLen))
buf[4] = byte(padLen)
copy(buf[5:], payload)
if _, err := rand.Read(buf[5+len(payload):]); err != nil {
return fmt.Errorf("padding rand: %w", err)
}
_, err := w.Write(buf)
return err
}
// buildKexInit crafts a client KEXINIT payload that advertises every
// algorithm family the Go SSH stack knows, plus the typical OpenSSH
// names we aren't implementing. We are never going to actually perform
// key exchange over this connection: the server only needs to accept
// our KEXINIT as well-formed and echo its own.
func buildKexInit() []byte {
var cookie [16]byte
_ = mustRand(cookie[:])
kex := strings.Join([]string{
"curve25519-sha256",
"curve25519-sha256@libssh.org",
"ecdh-sha2-nistp256",
"ecdh-sha2-nistp384",
"ecdh-sha2-nistp521",
"diffie-hellman-group-exchange-sha256",
"diffie-hellman-group16-sha512",
"diffie-hellman-group14-sha256",
"diffie-hellman-group14-sha1",
"diffie-hellman-group1-sha1",
"sntrup761x25519-sha512@openssh.com",
"mlkem768x25519-sha256",
}, ",")
hostKey := strings.Join([]string{
"ssh-ed25519",
"ssh-ed25519-cert-v01@openssh.com",
"rsa-sha2-512",
"rsa-sha2-256",
"ssh-rsa",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
"ecdsa-sha2-nistp521",
"ssh-dss",
}, ",")
ciphers := strings.Join([]string{
"chacha20-poly1305@openssh.com",
"aes256-gcm@openssh.com",
"aes128-gcm@openssh.com",
"aes256-ctr",
"aes192-ctr",
"aes128-ctr",
"aes256-cbc",
"aes128-cbc",
"3des-cbc",
}, ",")
macs := strings.Join([]string{
"hmac-sha2-512-etm@openssh.com",
"hmac-sha2-256-etm@openssh.com",
"umac-128-etm@openssh.com",
"hmac-sha2-512",
"hmac-sha2-256",
"hmac-sha1",
"hmac-sha1-96",
"hmac-md5",
}, ",")
comp := "none,zlib@openssh.com,zlib"
w := newPayloadWriter()
w.writeByte(msgKexInit)
w.writeBytes(cookie[:])
w.writeString(kex)
w.writeString(hostKey)
w.writeString(ciphers)
w.writeString(ciphers)
w.writeString(macs)
w.writeString(macs)
w.writeString(comp)
w.writeString(comp)
w.writeString("") // languages c2s
w.writeString("") // languages s2c
w.writeByte(0) // first_kex_packet_follows
w.writeUint32(0) // reserved
return w.bytes()
}
func mustRand(b []byte) error {
_, err := rand.Read(b)
return err
}
// parseKexInit parses a server KEXINIT payload. Validation is minimal:
// we do not reject over-long algorithm lists, we just trim them at the
// RFC ceiling so a hostile server can't make us allocate unbounded
// amounts of memory.
func parseKexInit(payload []byte) (*kexInitPayload, error) {
if len(payload) < 1 || payload[0] != msgKexInit {
return nil, fmt.Errorf("not a KEXINIT packet (first byte = %d)", func() byte {
if len(payload) == 0 {
return 0
}
return payload[0]
}())
}
r := newPayloadReader(payload[1:])
out := &kexInitPayload{}
if err := r.readBytes(out.Cookie[:]); err != nil {
return nil, err
}
var err error
if out.KexAlgorithms, err = r.readNameList(); err != nil {
return nil, err
}
if out.ServerHostKeyAlgorithms, err = r.readNameList(); err != nil {
return nil, err
}
if out.EncryptionAlgorithmsClientToSvr, err = r.readNameList(); err != nil {
return nil, err
}
if out.EncryptionAlgorithmsSvrToClient, err = r.readNameList(); err != nil {
return nil, err
}
if out.MACAlgorithmsClientToSvr, err = r.readNameList(); err != nil {
return nil, err
}
if out.MACAlgorithmsSvrToClient, err = r.readNameList(); err != nil {
return nil, err
}
if out.CompressionAlgorithmsClientToSv, err = r.readNameList(); err != nil {
return nil, err
}
if out.CompressionAlgorithmsSvrToClt, err = r.readNameList(); err != nil {
return nil, err
}
if out.LanguagesClientToSvr, err = r.readNameList(); err != nil {
return nil, err
}
if out.LanguagesSvrToClient, err = r.readNameList(); err != nil {
return nil, err
}
b, err := r.readByte()
if err != nil {
return nil, err
}
out.FirstKexPacketFollows = b != 0
return out, nil
}
// payloadWriter/Reader are tiny helpers for SSH wire encoding. We only
// ever use uint32-prefixed strings and comma-separated name-lists.
type payloadWriter struct{ buf []byte }
func newPayloadWriter() *payloadWriter { return &payloadWriter{} }
func (w *payloadWriter) bytes() []byte { return w.buf }
func (w *payloadWriter) writeByte(b byte) { w.buf = append(w.buf, b) }
func (w *payloadWriter) writeBytes(b []byte) { w.buf = append(w.buf, b...) }
func (w *payloadWriter) writeUint32(v uint32) {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
w.buf = append(w.buf, b[:]...)
}
func (w *payloadWriter) writeString(s string) {
w.writeUint32(uint32(len(s)))
w.buf = append(w.buf, s...)
}
type payloadReader struct {
buf []byte
pos int
}
func newPayloadReader(b []byte) *payloadReader { return &payloadReader{buf: b} }
func (r *payloadReader) readByte() (byte, error) {
if r.pos >= len(r.buf) {
return 0, io.ErrUnexpectedEOF
}
b := r.buf[r.pos]
r.pos++
return b, nil
}
func (r *payloadReader) readBytes(dst []byte) error {
if r.pos+len(dst) > len(r.buf) {
return io.ErrUnexpectedEOF
}
copy(dst, r.buf[r.pos:r.pos+len(dst)])
r.pos += len(dst)
return nil
}
func (r *payloadReader) readUint32() (uint32, error) {
if r.pos+4 > len(r.buf) {
return 0, io.ErrUnexpectedEOF
}
v := binary.BigEndian.Uint32(r.buf[r.pos : r.pos+4])
r.pos += 4
return v, nil
}
func (r *payloadReader) readNameList() ([]string, error) {
n, err := r.readUint32()
if err != nil {
return nil, err
}
if int(n) > len(r.buf)-r.pos {
return nil, io.ErrUnexpectedEOF
}
s := string(r.buf[r.pos : r.pos+int(n)])
r.pos += int(n)
if s == "" {
return nil, nil
}
return strings.Split(s, ","), nil
}

395
checker/prober.go Normal file
View file

@ -0,0 +1,395 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"bufio"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
)
// probeEndpoint runs the full probe flow on a single (host, ip, port)
// triple. It never returns a Go error: every failure mode is recorded
// as a raw field on SSHProbe (Stage + Error). Severity / pass/fail
// classification is performed later by CheckRule.Evaluate, never here.
func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout time.Duration, includeAuthProbe bool, sshfp SSHFPSummary) SSHProbe {
start := time.Now()
addr := net.JoinHostPort(ip, strconv.Itoa(int(port)))
p := SSHProbe{
Host: host,
Port: port,
Address: addr,
IP: ip,
IsIPv6: strings.Contains(ip, ":"),
Stage: "dial",
}
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
d := &net.Dialer{}
conn, err := d.DialContext(dialCtx, "tcp", addr)
if err != nil {
p.Error = "dial: " + err.Error()
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
defer conn.Close()
p.TCPConnected = true
p.Stage = "banner"
if deadline, ok := dialCtx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
// Phase 1: protocol banner exchange.
br := bufio.NewReader(conn)
banner, err := readBanner(br)
if err != nil {
p.Error = "banner: " + err.Error()
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
p.Banner = banner
p.ProtoVer, p.SoftVer, p.Vendor = parseBanner(banner)
p.Stage = "banner_write"
if err := writeBanner(conn); err != nil {
p.Error = "write-banner: " + err.Error()
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
// Phase 2: exchange KEXINIT.
p.Stage = "kexinit_read"
srvPayload, err := readPacket(br)
if err != nil {
p.Error = "kexinit-read: " + err.Error()
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
p.Stage = "kexinit_parse"
srvKex, err := parseKexInit(srvPayload)
if err != nil {
p.Error = "kexinit-parse: " + err.Error()
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
p.KEX = srvKex.KexAlgorithms
p.HostKey = srvKex.ServerHostKeyAlgorithms
p.CiphersC2S = srvKex.EncryptionAlgorithmsClientToSvr
p.CiphersS2C = srvKex.EncryptionAlgorithmsSvrToClient
p.MACsC2S = srvKex.MACAlgorithmsClientToSvr
p.MACsS2C = srvKex.MACAlgorithmsSvrToClient
p.CompC2S = srvKex.CompressionAlgorithmsClientToSv
p.CompS2C = srvKex.CompressionAlgorithmsSvrToClt
p.Stage = "kexinit_ok"
// We intentionally don't proceed with KEX here: algorithm posture is
// already captured. Closing now is friendlier than triggering a full
// exchange that might never terminate.
_ = conn.Close()
// We hand off to Go's ssh package for the full handshake. HostKeyCallback lets us
// capture each presented key without reimplementing DH/curve25519/kyber ourselves.
p.HostKeys = probeHostKeys(ctx, addr, host, srvKex.ServerHostKeyAlgorithms, timeout)
for i := range p.HostKeys {
p.HostKeys[i].applySSHFP(sshfp)
}
if len(p.HostKeys) > 0 {
p.Stage = "handshake_ok"
}
if includeAuthProbe {
p.AuthProbeAttempted = true
methods, err := probeAuthMethods(ctx, addr, timeout)
if err == nil {
p.AuthMethods = methods
for _, m := range methods {
switch m {
case "password":
p.PasswordAuth = true
case "keyboard-interactive":
p.KeyboardInteractive = true
case "publickey":
p.PublicKeyAuth = true
}
}
}
}
p.ElapsedMS = time.Since(start).Milliseconds()
return p
}
// Most deployments expose at most two or three key families (ed25519, rsa, ecdsa),
// so connecting once per family stays cheap.
func probeHostKeys(ctx context.Context, addr, host string, algos []string, timeout time.Duration) []HostKeyInfo {
wantFamilies := pickHostKeyFamilies(algos)
seen := map[string]bool{} // by sha256 hex, dedupe across families
var out []HostKeyInfo
for _, algo := range wantFamilies {
key, err := fetchHostKey(ctx, addr, host, algo, timeout)
if err != nil || key == nil {
continue
}
info := describeHostKey(key)
if seen[info.SHA256] {
continue
}
seen[info.SHA256] = true
out = append(out, info)
}
return out
}
// rsa-sha2-512 and rsa-sha2-256 both return the same RSA key, so we collapse by family.
func pickHostKeyFamilies(algos []string) []string {
var out []string
families := map[string]bool{}
add := func(family, algo string) {
if families[family] {
return
}
families[family] = true
out = append(out, algo)
}
for _, a := range algos {
switch a {
case "ssh-ed25519", "ssh-ed25519-cert-v01@openssh.com":
add("ed25519", "ssh-ed25519")
case "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa":
add("rsa", a)
case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
add("ecdsa", a)
case "ssh-dss":
add("dsa", "ssh-dss")
}
}
return out
}
// Offering no auth methods aborts the handshake at the auth step, which is enough
// to capture the host key without completing a full session.
func fetchHostKey(ctx context.Context, addr, host, algo string, timeout time.Duration) (ssh.PublicKey, error) {
var captured ssh.PublicKey
cfg := &ssh.ClientConfig{
User: "happydomain-checker",
Auth: nil,
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
captured = key
return nil
},
HostKeyAlgorithms: []string{algo},
Timeout: timeout,
ClientVersion: sshClientBanner,
}
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
d := &net.Dialer{}
conn, err := d.DialContext(dialCtx, "tcp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
if deadline, ok := dialCtx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
_, _, _, err = ssh.NewClientConn(conn, host, cfg)
if err != nil && captured == nil {
return nil, err
}
return captured, nil
}
// probeAuthMethods opens a fresh connection, completes the KEX, and
// then sends a "none" authentication request (RFC 4252 §5.2). The
// server's failure response carries the list of methods it would
// actually accept: exactly what we need.
func probeAuthMethods(ctx context.Context, addr string, timeout time.Duration) ([]string, error) {
cfg := &ssh.ClientConfig{
User: "happydomain-checker",
Auth: []ssh.AuthMethod{}, // forces a "none" attempt
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: timeout,
ClientVersion: sshClientBanner,
}
dialCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
d := &net.Dialer{}
conn, err := d.DialContext(dialCtx, "tcp", addr)
if err != nil {
return nil, err
}
defer conn.Close()
if deadline, ok := dialCtx.Deadline(); ok {
_ = conn.SetDeadline(deadline)
}
_, _, _, err = ssh.NewClientConn(conn, addr, cfg)
if err == nil {
// A server that lets us through "none" is unusual but possible
// (anonymous SSH for git-serve-style deployments); report that
// upstream by returning an empty list.
return nil, nil
}
return extractMethodsFromAuthError(err), nil
}
// x/crypto/ssh does not expose offered auth methods via a typed accessor; string
// parsing is the officially documented path.
func extractMethodsFromAuthError(err error) []string {
if err == nil {
return nil
}
msg := err.Error()
start := strings.Index(msg, "attempted methods [")
if start < 0 {
return nil
}
start += len("attempted methods [")
end := strings.Index(msg[start:], "]")
if end < 0 {
return nil
}
raw := strings.Fields(msg[start : start+end])
var out []string
for _, m := range raw {
if m == "none" {
continue
}
out = append(out, m)
}
return out
}
func describeHostKey(key ssh.PublicKey) HostKeyInfo {
marshaled := key.Marshal()
sha2 := sha256.Sum256(marshaled)
sha1sum := sha1.Sum(marshaled)
info := HostKeyInfo{
Type: key.Type(),
SHA256: hex.EncodeToString(sha2[:]),
SHA1: hex.EncodeToString(sha1sum[:]),
}
info.SSHFPAlgo = sshfpAlgoForKeyType(info.Type)
info.Bits = keyBits(key)
return info
}
// keyBits returns a key-family-specific size estimate. It is advisory:
// we only use it in the report, and a server that ships an RSA key
// smaller than 2048 bits is the sort of red flag we want to show.
func keyBits(key ssh.PublicKey) int {
switch k := key.(type) {
case ssh.CryptoPublicKey:
type bitSizer interface{ Size() int }
switch p := k.CryptoPublicKey().(type) {
case bitSizer:
return p.Size() * 8
default:
_ = p
}
}
return 0
}
// sshfpAlgoForKeyType maps an SSH host-key type string to the SSHFP
// algorithm number defined in RFC 4255 / RFC 6594 / RFC 7479.
func sshfpAlgoForKeyType(t string) uint8 {
switch t {
case "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512":
return 1
case "ssh-dss":
return 2
case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
return 3
case "ssh-ed25519":
return 4
}
return 0
}
// parseBanner splits an "SSH-2.0-OpenSSH_9.3p1 Debian-1" banner into
// (protocolVersion, softwareVersion, vendorComment). The grammar is
// RFC 4253 §4.2: "SSH-<protoversion>-<softwareversion> <comments>".
func parseBanner(b string) (proto, soft, vendor string) {
// SSH- prefix is guaranteed by readBanner.
rest := strings.TrimPrefix(b, "SSH-")
dash := strings.IndexByte(rest, '-')
if dash < 0 {
return rest, "", ""
}
proto = rest[:dash]
rest = rest[dash+1:]
if sp := strings.IndexByte(rest, ' '); sp >= 0 {
soft = rest[:sp]
vendor = strings.TrimSpace(rest[sp+1:])
} else {
soft = rest
}
return
}
// applySSHFP fills in the SSHFPMatchSHA* flags based on the declared
// SSHFP records for this key's algorithm family. These are raw
// observations (the record matched this key fingerprint); any
// severity verdict about coverage lives in the SSHFP rule.
func (h *HostKeyInfo) applySSHFP(s SSHFPSummary) {
for _, rr := range s.Records {
if rr.Algorithm != h.SSHFPAlgo {
continue
}
want := strings.ToLower(rr.Fingerprint)
switch rr.Type {
case 1:
if want == h.SHA1 {
h.SSHFPMatchSHA1 = true
}
case 2:
if want == h.SHA256 {
h.SSHFPMatchSHA256 = true
}
}
}
}
// errNoHostKey is returned by fetchHostKey when the callback never
// fired (e.g. transport-level error before the host key was received).
// Currently only used internally for readability.
var errNoHostKey = errors.New("no host key observed")

49
checker/provider.go Normal file
View file

@ -0,0 +1,49 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"encoding/json"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new SSH observation provider.
func Provider() sdk.ObservationProvider {
return &sshProvider{}
}
type sshProvider struct{}
func (p *sshProvider) Key() sdk.ObservationKey {
return ObservationKeySSH
}
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *sshProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
var d SSHData
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
return "", fmt.Errorf("unmarshal ssh observation: %w", err)
}
return renderReport(&d, rctx)
}

679
checker/report.go Normal file
View file

@ -0,0 +1,679 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"fmt"
"html/template"
"sort"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// renderReport builds the HTML iframe contents displayed in the
// happyDomain UI. The layout is deliberately close to the XMPP/TLS
// reports so operators get a consistent experience across checkers.
//
// Structure:
// 1. Header with overall status badge + SSHFP verdict chips.
// 2. "What to fix" list (the most common / highest-severity issues
// with inline copy-pasteable sshd_config or DNS snippets).
// 3. Per-endpoint details (banner, host keys, algorithm tables,
// auth methods).
//
// We render the algorithm tables with per-row severity classes so the
// weak/broken entries light up visually.
func renderReport(d *SSHData, rctx sdk.ReportContext) (string, error) {
var states []sdk.CheckState
if rctx != nil {
states = rctx.States()
}
view := buildReportData(d, states)
var buf strings.Builder
if err := reportTpl.Execute(&buf, view); err != nil {
return "", fmt.Errorf("render ssh report: %w", err)
}
return buf.String(), nil
}
type reportView struct {
Domain string
RunAt string
StatusLabel string
StatusClass string
HasIssues bool
TopFixes []reportFix
SSHFPPresent bool
SSHFPMatched bool
SSHFPRecords []reportSSHFPRecord
Endpoints []reportEndpoint
HasAuthProbe bool
AnyIPv4, AnyIPv6 bool
}
type reportFix struct {
Severity string
Code string
Message string
Fix string
Endpoint string
}
type reportSSHFPRecord struct {
Algorithm uint8
AlgoName string
Type uint8
TypeName string
Fingerprint string
Matched bool
}
type reportEndpoint struct {
Address string
Host string
Port uint16
IsIPv6 bool
TCPConnected bool
Banner string
SoftwareVer string
Vendor string
ElapsedMS int64
Error string
StatusLabel string
StatusClass string
AnyFail bool
HostKeys []reportHostKey
AlgoTables []reportAlgoTable
AuthMethods []reportAuthMethod
Issues []reportFix
}
type reportHostKey struct {
Type string
Bits int
SHA256 string
SHA1 string
SSHFPMatched bool
SSHFPFamily string
SSHFPSnippet string
}
type reportAlgoTable struct {
Title string
Rows []reportAlgoRow
}
type reportAlgoRow struct {
Name string
Severity string // "", "warn", "crit", "info"
Note string
}
type reportAuthMethod struct {
Name string
Severity string // "ok", "warn"
Note string
}
func buildReportData(d *SSHData, states []sdk.CheckState) reportView {
v := reportView{
Domain: d.Domain,
RunAt: d.CollectedAt.Format("2006-01-02 15:04 MST"),
SSHFPPresent: d.SSHFP.Present,
}
// Deduplicate: the same weak cipher reported by two endpoints merges into one row.
// When no states are available, fall back to data-only rendering with no hints.
type fix struct {
severity string
code string
message string
fixText string
endpoint string
}
stateFix := func(s sdk.CheckState) (fix, bool) {
sev := statusToSeverity(s.Status)
if sev == "" {
return fix{}, false
}
var fixText string
if s.Meta != nil {
if raw, ok := s.Meta["fix"]; ok {
if str, ok := raw.(string); ok {
fixText = str
}
}
}
return fix{
severity: sev,
code: s.Code,
message: s.Message,
fixText: fixText,
endpoint: s.Subject,
}, true
}
// Per-endpoint grouping by Subject (endpoint Address).
perEp := map[string][]fix{}
var allFixes []fix
seen := map[string]bool{}
for _, s := range states {
f, ok := stateFix(s)
if !ok {
continue
}
if f.endpoint != "" {
perEp[f.endpoint] = append(perEp[f.endpoint], f)
}
key := f.code + "|" + f.message
if seen[key] {
continue
}
seen[key] = true
allFixes = append(allFixes, f)
}
sort.SliceStable(allFixes, func(i, j int) bool {
return sevRank(allFixes[i].severity) < sevRank(allFixes[j].severity)
})
for _, f := range allFixes {
if f.severity == SeverityInfo && !strings.Contains(f.code, "sshfp") {
continue // informational clutter, keep in per-endpoint only
}
v.TopFixes = append(v.TopFixes, reportFix{
Severity: f.severity,
Code: f.code,
Message: f.message,
Fix: f.fixText,
Endpoint: f.endpoint,
})
}
v.HasIssues = len(v.TopFixes) > 0
worst := SeverityOK
for _, f := range allFixes {
if f.severity == SeverityCrit {
worst = SeverityCrit
break
}
if f.severity == SeverityWarn && worst != SeverityCrit {
worst = SeverityWarn
}
}
switch worst {
case SeverityCrit:
v.StatusLabel = "FAIL"
v.StatusClass = "fail"
case SeverityWarn:
v.StatusLabel = "WARN"
v.StatusClass = "warn"
default:
v.StatusLabel = "OK"
v.StatusClass = "ok"
}
// SSHFP records table.
for _, rr := range d.SSHFP.Records {
matched := false
for _, ep := range d.Endpoints {
for _, k := range ep.HostKeys {
if k.SSHFPAlgo == rr.Algorithm {
if rr.Type == 2 && strings.EqualFold(rr.Fingerprint, k.SHA256) {
matched = true
}
if rr.Type == 1 && strings.EqualFold(rr.Fingerprint, k.SHA1) {
matched = true
}
}
}
}
if matched {
v.SSHFPMatched = true
}
v.SSHFPRecords = append(v.SSHFPRecords, reportSSHFPRecord{
Algorithm: rr.Algorithm,
AlgoName: sshfpAlgoName(rr.Algorithm),
Type: rr.Type,
TypeName: sshfpHashName(rr.Type),
Fingerprint: rr.Fingerprint,
Matched: matched,
})
}
for _, ep := range d.Endpoints {
re := reportEndpoint{
Address: ep.Address,
Host: ep.Host,
Port: ep.Port,
IsIPv6: ep.IsIPv6,
TCPConnected: ep.TCPConnected,
Banner: ep.Banner,
SoftwareVer: ep.SoftVer,
Vendor: ep.Vendor,
ElapsedMS: ep.ElapsedMS,
Error: ep.Error,
}
if ep.IsIPv6 {
v.AnyIPv6 = true
} else {
v.AnyIPv4 = true
}
perEpIssues := perEp[ep.Address]
// Per-endpoint status label.
epWorst := SeverityOK
for _, f := range perEpIssues {
if f.severity == SeverityCrit {
epWorst = SeverityCrit
break
}
if f.severity == SeverityWarn && epWorst != SeverityCrit {
epWorst = SeverityWarn
}
}
switch epWorst {
case SeverityCrit:
re.StatusLabel = "FAIL"
re.StatusClass = "fail"
re.AnyFail = true
case SeverityWarn:
re.StatusLabel = "WARN"
re.StatusClass = "warn"
default:
re.StatusLabel = "OK"
re.StatusClass = "ok"
}
for _, k := range ep.HostKeys {
rh := reportHostKey{
Type: k.Type,
Bits: k.Bits,
SHA256: k.SHA256,
SHA1: k.SHA1,
}
rh.SSHFPMatched = k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1
rh.SSHFPFamily = sshfpAlgoName(k.SSHFPAlgo)
rh.SSHFPSnippet = fmt.Sprintf("%d 2 %s", k.SSHFPAlgo, k.SHA256)
re.HostKeys = append(re.HostKeys, rh)
}
re.AlgoTables = []reportAlgoTable{
{Title: "Key exchange (KEX)", Rows: algoRows(ep.KEX, kexAlgos)},
{Title: "Server host keys", Rows: algoRows(ep.HostKey, hostKeyAlgos)},
{Title: "Ciphers", Rows: algoRows(uniqueMerge(ep.CiphersC2S, ep.CiphersS2C), cipherAlgos)},
{Title: "MACs", Rows: algoRows(uniqueMerge(ep.MACsC2S, ep.MACsS2C), macAlgos)},
}
if ep.AuthMethods != nil || ep.PasswordAuth || ep.PublicKeyAuth || ep.KeyboardInteractive {
v.HasAuthProbe = true
for _, m := range ep.AuthMethods {
sev := SeverityOK
note := ""
switch m {
case "password":
sev = SeverityWarn
note = "password auth over the internet is the #1 brute-force target"
case "keyboard-interactive":
note = "often used for 2FA, otherwise equivalent to password"
case "publickey":
note = "preferred method"
}
re.AuthMethods = append(re.AuthMethods, reportAuthMethod{
Name: m,
Severity: sev,
Note: note,
})
}
}
for _, f := range perEpIssues {
re.Issues = append(re.Issues, reportFix{
Severity: f.severity,
Code: f.code,
Message: f.message,
Fix: f.fixText,
})
}
v.Endpoints = append(v.Endpoints, re)
}
return v
}
// Non-finding statuses return "" so callers skip them in fix listings.
func statusToSeverity(s sdk.Status) string {
switch s {
case sdk.StatusCrit:
return SeverityCrit
case sdk.StatusWarn:
return SeverityWarn
case sdk.StatusInfo:
return SeverityInfo
}
return ""
}
func algoRows(list []string, table map[string]algoVerdict) []reportAlgoRow {
out := make([]reportAlgoRow, 0, len(list))
for _, name := range list {
v := verdictFor(table, name)
out = append(out, reportAlgoRow{
Name: name,
Severity: v.severity,
Note: v.reason,
})
}
return out
}
func sevRank(s string) int {
switch s {
case SeverityCrit:
return 0
case SeverityWarn:
return 1
case SeverityInfo:
return 2
}
return 3
}
func sshfpAlgoName(a uint8) string {
switch a {
case 1:
return "RSA"
case 2:
return "DSA"
case 3:
return "ECDSA"
case 4:
return "Ed25519"
case 6:
return "Ed448"
}
return fmt.Sprintf("algo %d", a)
}
func sshfpHashName(t uint8) string {
switch t {
case 1:
return "SHA-1"
case 2:
return "SHA-256"
}
return fmt.Sprintf("hash %d", t)
}
var reportTpl = template.Must(template.New("ssh").Funcs(template.FuncMap{
"sevClass": func(s string) string {
switch s {
case SeverityCrit:
return "fail"
case SeverityWarn:
return "warn"
case SeverityInfo:
return "muted"
case SeverityOK:
return "ok"
}
return ""
},
}).Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSH Report: {{.Domain}}</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.5;
color: #1f2937;
background: #f3f4f6;
}
body { margin: 0; padding: 1rem; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
pre { font-family: ui-monospace, monospace; font-size: .78rem; background: #111827; color: #e5e7eb; padding: .6rem .8rem; border-radius: 6px; overflow-x: auto; margin: .35rem 0 0; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
h3 { font-size: .9rem; font-weight: 600; margin: .5rem 0 .3rem; }
.hd, .section, details {
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,.08);
}
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
.badge {
display: inline-flex; align-items: center;
padding: .2em .65em; border-radius: 9999px;
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
}
.ok { background: #d1fae5; color: #065f46; }
.warn { background: #fef3c7; color: #92400e; }
.fail { background: #fee2e2; color: #991b1b; }
.muted { background: #e5e7eb; color: #374151; }
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
summary {
display: flex; align-items: center; gap: .5rem;
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
}
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
details[open] > summary::before { transform: rotate(90deg); }
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; margin-top: .25rem; }
th, td { text-align: left; padding: .25rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
tr.crit td:first-child { border-left: 3px solid #dc2626; }
tr.warn td:first-child { border-left: 3px solid #f59e0b; }
tr.info td:first-child { border-left: 3px solid #3b82f6; }
.fix {
border-left: 3px solid #dc2626;
padding: .5rem .75rem; margin-bottom: .5rem;
background: #fef2f2; border-radius: 0 6px 6px 0;
}
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
.fix.info { border-color: #3b82f6; background: #eff6ff; }
.fix.muted { border-color: #9ca3af; background: #f9fafb; }
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
.fix .how { font-size: .88rem; }
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
.chip {
display: inline-block; padding: .12em .5em;
background: #e0e7ff; color: #3730a3;
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
}
.chip.fail { background: #fee2e2; color: #991b1b; }
.chip.warn { background: #fef3c7; color: #92400e; }
.chip.ok { background: #d1fae5; color: #065f46; }
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
.kv dt { color: #6b7280; }
.kv dd { margin: 0; }
.note { color: #6b7280; font-size: .85rem; }
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
.check-ok { color: #059669; }
.check-fail { color: #dc2626; }
.fp {
font-family: ui-monospace, monospace; font-size: .72rem;
word-break: break-all;
}
</style>
</head>
<body>
<div class="hd">
<h1>SSH: <code>{{.Domain}}</code></h1>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
<div class="meta">
{{if .SSHFPPresent}}
{{if .SSHFPMatched}}<span class="badge ok">SSHFP verified</span>
{{else}}<span class="badge fail">SSHFP mismatch</span>{{end}}
{{else}}<span class="badge muted">no SSHFP</span>{{end}}
{{if .AnyIPv4}}<span class="badge muted">IPv4</span>{{end}}
{{if .AnyIPv6}}<span class="badge muted">IPv6</span>{{end}}
</div>
<div class="meta">Checked {{.RunAt}}</div>
</div>
{{if .HasIssues}}
<div class="section">
<h2>What to fix</h2>
{{range .TopFixes}}
<div class="fix {{sevClass .Severity}}">
<div class="code">{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
</div>
{{end}}
{{if .SSHFPPresent}}
<div class="section">
<h2>SSHFP records</h2>
<table>
<tr><th>Algorithm</th><th>Hash</th><th>Fingerprint</th><th>Status</th></tr>
{{range .SSHFPRecords}}
<tr>
<td>{{.AlgoName}} ({{.Algorithm}})</td>
<td>{{.TypeName}} ({{.Type}})</td>
<td class="fp">{{.Fingerprint}}</td>
<td>{{if .Matched}}<span class="badge ok">match</span>{{else}}<span class="badge warn">no match</span>{{end}}</td>
</tr>
{{end}}
</table>
</div>
{{else}}
<div class="section">
<h2>SSHFP records</h2>
<p class="note">No SSHFP records are published for this service. Clients trust the host key the first time they connect (TOFU). Publishing SSHFP records (with DNSSEC) lets clients verify the server automatically.</p>
</div>
{{end}}
{{if .Endpoints}}
<div class="section">
<h2>Endpoints ({{len .Endpoints}})</h2>
{{range .Endpoints}}
<details{{if .AnyFail}} open{{end}}>
<summary>
<span class="conn-addr">{{.Address}}{{if .Banner}} · {{.Banner}}{{end}}</span>
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
</summary>
<div class="details-body">
<dl class="kv">
<dt>Host</dt><dd>{{.Host}}</dd>
<dt>IP</dt><dd><code>{{.Address}}</code>{{if .IsIPv6}} (IPv6){{end}}</dd>
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">&#10003; connected</span>{{else}}<span class="check-fail">&#10007; failed</span>{{end}}</dd>
{{if .SoftwareVer}}<dt>Version</dt><dd><code>{{.SoftwareVer}}</code>{{if .Vendor}} · <span class="note">{{.Vendor}}</span>{{end}}</dd>{{end}}
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
</dl>
{{if .HostKeys}}
<h3>Host keys</h3>
<table>
<tr><th>Type</th><th>Bits</th><th>SHA-256 fingerprint</th><th>SSHFP</th></tr>
{{range .HostKeys}}
<tr>
<td>{{.Type}}</td>
<td>{{if .Bits}}{{.Bits}}{{else}}-{{end}}</td>
<td class="fp">{{.SHA256}}</td>
<td>
{{if .SSHFPMatched}}<span class="badge ok">verified</span>
{{else}}<span class="badge warn">no match</span>
<div class="note">Add: <code>IN SSHFP {{.SSHFPSnippet}}</code></div>
{{end}}
</td>
</tr>
{{end}}
</table>
{{end}}
{{range .AlgoTables}}
{{if .Rows}}
<h3>{{.Title}}</h3>
<table>
<tr><th>Algorithm</th><th>Verdict</th></tr>
{{range .Rows}}
<tr class="{{.Severity}}">
<td><code>{{.Name}}</code></td>
<td>
{{if eq .Severity "crit"}}<span class="badge fail">broken</span>
{{else if eq .Severity "warn"}}<span class="badge warn">weak</span>
{{else if eq .Severity "info"}}<span class="badge muted">info</span>
{{else if eq .Severity "ok"}}<span class="badge ok">good</span>
{{else}}<span class="badge ok">OK</span>{{end}}
{{if .Note}} <span class="note">{{.Note}}</span>{{end}}
</td>
</tr>
{{end}}
</table>
{{end}}
{{end}}
{{if .AuthMethods}}
<h3>Authentication methods</h3>
<div class="chiprow">
{{range .AuthMethods}}<span class="chip {{sevClass .Severity}}">{{.Name}}</span>{{end}}
</div>
{{end}}
{{if .Issues}}
<h3>Findings</h3>
{{range .Issues}}
<div class="fix {{sevClass .Severity}}">
<div class="code">{{.Code}}</div>
<div class="msg">{{.Message}}</div>
{{if .Fix}}<div class="how">&rarr; {{.Fix}}</div>{{end}}
</div>
{{end}}
{{end}}
</div>
</details>
{{end}}
</div>
{{end}}
<p class="footer">SSH checker: algorithm posture inspired by <a href="https://github.com/jtesta/ssh-audit">ssh-audit</a>. For client-side audits, run that tool locally.</p>
</body>
</html>`))

137
checker/rules.go Normal file
View file

@ -0,0 +1,137 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Each concern is its own rule so results surface independently in the UI
// rather than being squashed under a single aggregated verdict.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&reachabilityRule{},
&handshakeRule{},
&protocolVersionRule{},
&bannerSoftwareRule{},
&knownVulnsRule{},
newKexAlgorithmsRule(),
newHostKeyAlgorithmsRule(),
newCipherAlgorithmsRule(),
newMacAlgorithmsRule(),
&strictKexRule{},
&preauthCompressionRule{},
&hostKeyStrengthRule{},
&sshfpAlignmentRule{},
&sshfpHashRule{},
&authMethodsRule{},
}
}
// On failure, returns a single error state the caller should emit to short-circuit its rule.
func loadSSHData(ctx context.Context, obs sdk.ObservationGetter) (*SSHData, *sdk.CheckState) {
var data SSHData
if err := obs.Get(ctx, ObservationKeySSH, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to load SSH observation: %v", err),
Code: "ssh.observation_error",
}
}
return &data, nil
}
// reachableEndpoints returns the subset of endpoints that completed
// enough of the handshake to expose algorithm data.
func reachableEndpoints(eps []SSHProbe) []SSHProbe {
var out []SSHProbe
for _, ep := range eps {
if len(ep.KEX) > 0 {
out = append(out, ep)
}
}
return out
}
// severityToStatus maps an Issue severity to the SDK Status enum.
func severityToStatus(sev string) sdk.Status {
switch sev {
case SeverityCrit:
return sdk.StatusCrit
case SeverityWarn:
return sdk.StatusWarn
case SeverityInfo:
return sdk.StatusInfo
default:
return sdk.StatusOK
}
}
func issueToState(is Issue) sdk.CheckState {
st := sdk.CheckState{
Status: severityToStatus(is.Severity),
Message: is.Message,
Code: is.Code,
Subject: is.Endpoint,
}
if is.Fix != "" {
st.Meta = map[string]any{"fix": is.Fix}
}
return st
}
func statesFromIssues(issues []Issue) []sdk.CheckState {
out := make([]sdk.CheckState, 0, len(issues))
for _, is := range issues {
out = append(out, issueToState(is))
}
return out
}
func passState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusOK,
Message: message,
Code: code,
}
}
func notTestedState(code, message string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Message: message,
Code: code,
}
}
// noEndpointsState is returned by rules that need probe output but got
// nothing (no endpoints collected at all).
func noEndpointsState(code string) sdk.CheckState {
return sdk.CheckState{
Status: sdk.StatusUnknown,
Message: "No SSH endpoints were probed.",
Code: code,
}
}

174
checker/rules_algorithms.go Normal file
View file

@ -0,0 +1,174 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// algorithmFamilyRule is the shared implementation for the four
// algorithm posture rules (KEX, host key, cipher, MAC). Each one
// inspects a different field on SSHProbe and uses a different catalog.
type algorithmFamilyRule struct {
ruleName string
description string
passCode string
passMsg string
family string
extract func(p *SSHProbe) []string
table map[string]algoVerdict
}
func (r *algorithmFamilyRule) Name() string { return r.ruleName }
func (r *algorithmFamilyRule) Description() string { return r.description }
func (r *algorithmFamilyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
eps := reachableEndpoints(data.Endpoints)
if len(eps) == 0 {
return []sdk.CheckState{notTestedState(r.ruleName+".skipped", "No endpoint produced an algorithm listing.")}
}
var issues []Issue
for _, ep := range eps {
issues = append(issues, analyseWeakAlgos(ep.Address, r.family, r.extract(&ep), r.table)...)
}
if len(issues) == 0 {
return []sdk.CheckState{passState(r.passCode, r.passMsg)}
}
return statesFromIssues(issues)
}
type kexAlgorithmsRule struct{ algorithmFamilyRule }
func newKexAlgorithmsRule() *kexAlgorithmsRule {
return &kexAlgorithmsRule{algorithmFamilyRule{
ruleName: "ssh.kex_algorithms",
description: "Flags key-exchange algorithms advertised by the server that are weak or broken.",
passCode: "ssh.kex_algorithms.ok",
passMsg: "Every advertised KEX algorithm is modern.",
family: "kex",
extract: func(p *SSHProbe) []string { return p.KEX },
table: kexAlgos,
}}
}
type hostKeyAlgorithmsRule struct{ algorithmFamilyRule }
func newHostKeyAlgorithmsRule() *hostKeyAlgorithmsRule {
return &hostKeyAlgorithmsRule{algorithmFamilyRule{
ruleName: "ssh.host_key_algorithms",
description: "Flags server host-key algorithms that are weak or deprecated (ssh-rsa/SHA-1, ssh-dss, …).",
passCode: "ssh.host_key_algorithms.ok",
passMsg: "Every advertised host-key algorithm is modern.",
family: "hostkey_alg",
extract: func(p *SSHProbe) []string { return p.HostKey },
table: hostKeyAlgos,
}}
}
type cipherAlgorithmsRule struct{ algorithmFamilyRule }
func newCipherAlgorithmsRule() *cipherAlgorithmsRule {
return &cipherAlgorithmsRule{algorithmFamilyRule{
ruleName: "ssh.cipher_algorithms",
description: "Flags symmetric ciphers advertised by the server that are weak or broken (CBC, 3DES, RC4, …).",
passCode: "ssh.cipher_algorithms.ok",
passMsg: "Every advertised cipher is modern.",
family: "cipher",
extract: func(p *SSHProbe) []string { return uniqueMerge(p.CiphersC2S, p.CiphersS2C) },
table: cipherAlgos,
}}
}
type macAlgorithmsRule struct{ algorithmFamilyRule }
func newMacAlgorithmsRule() *macAlgorithmsRule {
return &macAlgorithmsRule{algorithmFamilyRule{
ruleName: "ssh.mac_algorithms",
description: "Flags MAC algorithms advertised by the server that are weak (SHA-1, non-ETM, …).",
passCode: "ssh.mac_algorithms.ok",
passMsg: "Every advertised MAC algorithm is modern.",
family: "mac",
extract: func(p *SSHProbe) []string { return uniqueMerge(p.MACsC2S, p.MACsS2C) },
table: macAlgos,
}}
}
// strictKexRule flags the absence of the Terrapin mitigation marker.
type strictKexRule struct{}
func (r *strictKexRule) Name() string { return "ssh.strict_kex" }
func (r *strictKexRule) Description() string {
return "Verifies the server advertises the strict-KEX marker (CVE-2023-48795 Terrapin mitigation)."
}
func (r *strictKexRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
eps := reachableEndpoints(data.Endpoints)
if len(eps) == 0 {
return []sdk.CheckState{notTestedState("ssh.strict_kex.skipped", "No endpoint produced an algorithm listing.")}
}
var issues []Issue
for _, ep := range eps {
issues = append(issues, analyseStrictKex(ep.Address, ep.KEX)...)
}
if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.strict_kex.ok", "Every endpoint advertises the Terrapin mitigation marker.")}
}
return statesFromIssues(issues)
}
// preauthCompressionRule flags servers offering "zlib" (pre-auth)
// compression alongside / instead of zlib@openssh.com (post-auth).
type preauthCompressionRule struct{}
func (r *preauthCompressionRule) Name() string { return "ssh.preauth_compression" }
func (r *preauthCompressionRule) Description() string {
return "Flags servers that offer pre-authentication zlib compression (prefer zlib@openssh.com)."
}
func (r *preauthCompressionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
eps := reachableEndpoints(data.Endpoints)
if len(eps) == 0 {
return []sdk.CheckState{notTestedState("ssh.preauth_compression.skipped", "No endpoint produced compression data.")}
}
var issues []Issue
for _, ep := range eps {
issues = append(issues, analysePreauthCompression(ep.Address, ep.CompC2S)...)
}
if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.preauth_compression.ok", "No endpoint offers pre-authentication zlib compression.")}
}
return statesFromIssues(issues)
}

61
checker/rules_auth.go Normal file
View file

@ -0,0 +1,61 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// authMethodsRule reports on the authentication methods advertised by
// the server (password exposure, missing public-key support). Only
// active when the auth probe ran.
type authMethodsRule struct{}
func (r *authMethodsRule) Name() string { return "ssh.auth_methods" }
func (r *authMethodsRule) Description() string {
return "Reviews the advertised authentication methods (password exposure, public-key availability)."
}
func (r *authMethodsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var probed bool
var issues []Issue
for _, ep := range data.Endpoints {
if !ep.AuthProbeAttempted {
continue
}
probed = true
issues = append(issues, analyseAuthMethods(ep.Address, &ep)...)
}
if !probed {
return []sdk.CheckState{notTestedState("ssh.auth_methods.skipped", "Authentication-method enumeration disabled or not performed.")}
}
if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.auth_methods.ok", "Authentication method posture looks sound.")}
}
return statesFromIssues(issues)
}

122
checker/rules_banner.go Normal file
View file

@ -0,0 +1,122 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// protocolVersionRule flags servers advertising SSH-1.x, which has
// not been safe for decades.
type protocolVersionRule struct{}
func (r *protocolVersionRule) Name() string { return "ssh.protocol_version" }
func (r *protocolVersionRule) Description() string {
return "Verifies every endpoint advertises SSH-2 and rejects the legacy SSH-1 protocol."
}
func (r *protocolVersionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{noEndpointsState("ssh.protocol_version.no_endpoints")}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Banner == "" {
continue
}
if !strings.HasPrefix(ep.ProtoVer, "2.") && ep.ProtoVer != "2" {
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "ssh_legacy_protocol",
Subject: ep.Address,
Message: fmt.Sprintf("Server advertises SSH protocol %q (banner %q). SSH-1 is obsolete and insecure.", ep.ProtoVer, ep.Banner),
Meta: map[string]any{"fix": "Disable SSH-1 support; run an sshd that only speaks SSH-2."},
})
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ssh.protocol_version.ok", "Every endpoint advertises SSH-2.")}
}
return states
}
// bannerSoftwareRule reports when a server's software identifier does
// not look like a recognised OpenSSH build.
type bannerSoftwareRule struct{}
func (r *bannerSoftwareRule) Name() string { return "ssh.banner_software" }
func (r *bannerSoftwareRule) Description() string {
return "Flags servers whose banner is not a recognised OpenSSH build (so their maintenance status cannot be inferred)."
}
func (r *bannerSoftwareRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{noEndpointsState("ssh.banner_software.no_endpoints")}
}
var issues []Issue
for _, ep := range data.Endpoints {
issues = append(issues, analyseBannerSoftware(ep.Address, ep.Banner, ep.SoftVer)...)
}
if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.banner_software.ok", "All probed servers advertise a recognised OpenSSH build.")}
}
return statesFromIssues(issues)
}
// knownVulnsRule maps the observed banner against an OpenSSH CVE
// catalog and emits one state per matched vulnerability.
type knownVulnsRule struct{}
func (r *knownVulnsRule) Name() string { return "ssh.known_vulnerabilities" }
func (r *knownVulnsRule) Description() string {
return "Matches the advertised OpenSSH version against a curated catalog of remotely-observable CVEs."
}
func (r *knownVulnsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{noEndpointsState("ssh.known_vulnerabilities.no_endpoints")}
}
var issues []Issue
for _, ep := range data.Endpoints {
issues = append(issues, analyseBannerVulns(ep.Address, ep.Banner, ep.SoftVer)...)
}
if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.known_vulnerabilities.ok", "No known CVE match against the advertised OpenSSH versions.")}
}
return statesFromIssues(issues)
}

64
checker/rules_hostkey.go Normal file
View file

@ -0,0 +1,64 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// hostKeyStrengthRule flags host keys whose size is below what modern
// OpenSSH requires (currently < 2048-bit RSA).
type hostKeyStrengthRule struct{}
func (r *hostKeyStrengthRule) Name() string { return "ssh.host_key_strength" }
func (r *hostKeyStrengthRule) Description() string {
return "Flags SSH host keys whose size is below the currently accepted minimum (e.g. RSA < 2048 bits)."
}
func (r *hostKeyStrengthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var anyKey bool
var issues []Issue
for _, ep := range data.Endpoints {
if len(ep.HostKeys) > 0 {
anyKey = true
}
// Also flag endpoints that reached KEXINIT but failed to
// produce any host key: the handshake didn't complete.
if len(ep.KEX) > 0 {
issues = append(issues, analyseHandshakeHostKey(ep.Address, true, ep.HostKeys)...)
}
issues = append(issues, analyseHostKeyStrength(ep.Address, ep.HostKeys)...)
}
if !anyKey && len(issues) == 0 {
return []sdk.CheckState{notTestedState("ssh.host_key_strength.skipped", "No host key observed on any reachable endpoint.")}
}
if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.host_key_strength.ok", "Every observed host key meets the minimum accepted size.")}
}
return statesFromIssues(issues)
}

View file

@ -0,0 +1,134 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
"fmt"
"strconv"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// reachabilityRule reports per-endpoint TCP reachability. One state
// per probed (ip, port) pair so the UI distinguishes individual
// firewall / routing issues.
type reachabilityRule struct{}
func (r *reachabilityRule) Name() string { return "ssh.tcp_reachable" }
func (r *reachabilityRule) Description() string {
return "Verifies that every probed (address, port) pair accepts a TCP connection."
}
func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{noEndpointsState("ssh.tcp_reachable.no_endpoints")}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.TCPConnected {
continue
}
msg := "Cannot open TCP connection to " + ep.Address
if ep.Error != "" {
msg += ": " + ep.Error
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Message: msg,
Code: "tcp_unreachable",
Subject: ep.Address,
Meta: map[string]any{
"fix": "Check DNS, firewall (allow tcp/" + strconv.Itoa(int(ep.Port)) + " from the internet), and that sshd is running.",
},
})
}
if len(states) == 0 {
return []sdk.CheckState{passState("ssh.tcp_reachable.ok", "All probed endpoints accept TCP connections.")}
}
return states
}
// handshakeRule reports per-endpoint SSH handshake progress: whether
// banner exchange and KEXINIT parsing completed. Endpoints that are
// TCP-unreachable are skipped (covered by reachabilityRule).
type handshakeRule struct{}
func (r *handshakeRule) Name() string { return "ssh.handshake" }
func (r *handshakeRule) Description() string {
return "Verifies that the SSH banner exchange and KEXINIT parse succeed on every reachable endpoint."
}
func (r *handshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{noEndpointsState("ssh.handshake.no_endpoints")}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if !ep.TCPConnected {
continue
}
switch ep.Stage {
case "banner":
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "no_ssh_banner",
Subject: ep.Address,
Message: fmt.Sprintf("Server on %s did not send an SSH-2.0 banner: %s", ep.Address, ep.Error),
Meta: map[string]any{"fix": "Check that an SSH daemon (not HTTP, mail, ...) listens on this port."},
})
case "banner_write":
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "banner_write_failed",
Subject: ep.Address,
Message: "Failed to send our client banner: " + ep.Error,
})
case "kexinit_read":
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "kexinit_read_failed",
Subject: ep.Address,
Message: "Server did not send KEXINIT after banner: " + ep.Error,
})
case "kexinit_parse":
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "kexinit_parse_failed",
Subject: ep.Address,
Message: "Malformed KEXINIT packet: " + ep.Error,
})
}
}
if len(states) == 0 {
return []sdk.CheckState{passState("ssh.handshake.ok", "All reachable endpoints completed the SSH handshake.")}
}
return states
}

87
checker/rules_sshfp.go Normal file
View file

@ -0,0 +1,87 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// sshfpAlignmentRule compares the published SSHFP records with the
// observed host keys (match, missing, mismatch, uncovered family).
type sshfpAlignmentRule struct{}
func (r *sshfpAlignmentRule) Name() string { return "ssh.sshfp_alignment" }
func (r *sshfpAlignmentRule) Description() string {
return "Compares published SSHFP records against the observed host keys (match, missing, mismatch)."
}
func (r *sshfpAlignmentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var issues []Issue
sawKey := false
for _, ep := range data.Endpoints {
if len(ep.HostKeys) == 0 {
continue
}
sawKey = true
issues = append(issues, analyseSSHFPAlignment(ep.Address, ep.HostKeys, data.SSHFP)...)
}
if !sawKey {
return []sdk.CheckState{notTestedState("ssh.sshfp_alignment.skipped", "No host key observed; SSHFP alignment cannot be assessed.")}
}
if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.sshfp_alignment.ok", "Published SSHFP records match the observed host keys.")}
}
return statesFromIssues(issues)
}
// sshfpHashRule flags an SSHFP set that uses only the deprecated SHA-1
// (type 1) hash variant.
type sshfpHashRule struct{}
func (r *sshfpHashRule) Name() string { return "ssh.sshfp_hash" }
func (r *sshfpHashRule) Description() string {
return "Flags SSHFP record sets that only publish SHA-1 (type 1) fingerprints instead of SHA-256."
}
func (r *sshfpHashRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadSSHData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if !data.SSHFP.Present {
return []sdk.CheckState{notTestedState("ssh.sshfp_hash.skipped", "No SSHFP records published.")}
}
var issues []Issue
for _, ep := range data.Endpoints {
issues = append(issues, analyseSSHFPHashes(ep.Address, ep.HostKeys, data.SSHFP)...)
}
if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.sshfp_hash.ok", "SSHFP records include a SHA-256 (type 2) fingerprint.")}
}
return statesFromIssues(issues)
}

47
checker/service.go Normal file
View file

@ -0,0 +1,47 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"encoding/json"
"github.com/miekg/dns"
)
// serviceMessage mirrors happydns.ServiceMessage for the tiny subset
// of fields this checker reads or produces. Keeping a local copy lets
// us drop the happyDomain module dependency while preserving the
// on-the-wire JSON shape that the host emits when AutoFillService
// hands us an abstract.Server payload.
type serviceMessage struct {
Type string `json:"_svctype"`
Domain string `json:"_domain"`
Service json.RawMessage `json:"Service"`
}
// abstractServer mirrors services/abstract.Server: the A/AAAA/SSHFP
// records associated to a host in a zone.
type abstractServer struct {
A *dns.A `json:"A,omitempty"`
AAAA *dns.AAAA `json:"AAAA,omitempty"`
SSHFP []*dns.SSHFP `json:"SSHFP,omitempty"`
}

174
checker/sshfp.go Normal file
View file

@ -0,0 +1,174 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"fmt"
"strings"
)
// analyseHandshakeHostKey flags an endpoint where the full handshake
// never yielded any host key.
func analyseHandshakeHostKey(addr string, reached bool, keys []HostKeyInfo) []Issue {
if !reached || len(keys) > 0 {
return nil
}
return []Issue{{
Code: "no_host_key",
Severity: SeverityCrit,
Message: "Could not retrieve any SSH host key; the full handshake failed.",
Fix: "Check that the server accepts curve25519-sha256 or a similarly modern KEX, and that firewalls don't terminate the TLS-less SSH transport mid-flight.",
Endpoint: addr,
}}
}
// analyseHostKeyStrength flags host keys whose size is below the
// minimum accepted by modern OpenSSH.
func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue {
var issues []Issue
for _, k := range keys {
if k.SSHFPAlgo == 1 && k.Bits > 0 && k.Bits < 2048 {
issues = append(issues, Issue{
Code: "short_rsa_host_key",
Severity: SeverityCrit,
Message: fmt.Sprintf("RSA host key is %d bits; OpenSSH has rejected < 2048 bits since 8.2.", k.Bits),
Fix: "Regenerate the host key: rm /etc/ssh/ssh_host_rsa_key && ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ''",
Endpoint: addr,
})
}
}
return issues
}
// analyseSSHFPAlignment returns per-key alignment issues: match,
// no coverage for a key family, or mismatch between DNS and server.
func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue {
if len(keys) == 0 {
return nil
}
if !s.Present {
return []Issue{{
Code: "sshfp_missing",
Severity: SeverityInfo,
Message: "No SSHFP records published. Clients currently trust-on-first-use this server's host key.",
Fix: fmt.Sprintf("Publish SSHFP records under this service. Example for the ed25519 key: `IN SSHFP 4 2 %s`.", firstSHA256(keys)),
Endpoint: addr,
}}
}
var issues []Issue
coveredFamily := map[uint8]bool{}
for _, rr := range s.Records {
coveredFamily[rr.Algorithm] = true
}
for _, k := range keys {
if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 {
issues = append(issues, Issue{
Code: "sshfp_verified",
Severity: SeverityInfo,
Message: fmt.Sprintf("Host key %s (%s) matches the published SSHFP record.", k.Type, shortFP(k.SHA256)),
Endpoint: addr,
})
continue
}
if !coveredFamily[k.SSHFPAlgo] {
issues = append(issues, Issue{
Code: "sshfp_not_covered",
Severity: SeverityWarn,
Message: fmt.Sprintf("No SSHFP record covers host-key algorithm %s.", k.Type),
Fix: fmt.Sprintf("Add `IN SSHFP %d 2 %s` to the zone.", k.SSHFPAlgo, k.SHA256),
Endpoint: addr,
})
continue
}
issues = append(issues, Issue{
Code: "sshfp_mismatch",
Severity: SeverityCrit,
Message: fmt.Sprintf("Published SSHFP record does not match the %s host key presented by %s. Either the server key was rotated without updating DNS, or the server is impersonated.", k.Type, addr),
Fix: fmt.Sprintf("Update the SSHFP record to the current fingerprint: `IN SSHFP %d 2 %s`, and investigate why DNS and the server disagree.", k.SSHFPAlgo, k.SHA256),
Endpoint: addr,
})
}
return issues
}
// analyseSSHFPHashes flags a server whose published SSHFP records only
// use the deprecated SHA-1 (type 1) hash variant and where at least
// one of those records matched an observed key.
func analyseSSHFPHashes(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue {
if !s.Present {
return nil
}
matchedAny := false
for _, k := range keys {
if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 {
matchedAny = true
break
}
}
if !matchedAny {
return nil
}
for _, rr := range s.Records {
if rr.Type == 2 {
return nil
}
}
return []Issue{{
Code: "sshfp_only_sha1",
Severity: SeverityWarn,
Message: "SSHFP records use only SHA-1 (type 1) fingerprints. SHA-1 is deprecated for this use.",
Fix: "Add SHA-256 (type 2) SSHFP records alongside (or instead of) the existing SHA-1 ones.",
Endpoint: addr,
}}
}
// analyseHostKeys is a convenience wrapper used by the HTML report.
// reachedKexInit signals whether the handshake made it far enough for
// the absence of host keys to be meaningful (i.e. the server accepted
// our KEXINIT).
func analyseHostKeys(addr string, keys []HostKeyInfo, s SSHFPSummary, reachedKexInit bool) []Issue {
var issues []Issue
issues = append(issues, analyseHandshakeHostKey(addr, reachedKexInit, keys)...)
issues = append(issues, analyseHostKeyStrength(addr, keys)...)
issues = append(issues, analyseSSHFPAlignment(addr, keys, s)...)
issues = append(issues, analyseSSHFPHashes(addr, keys, s)...)
return issues
}
func firstSHA256(keys []HostKeyInfo) string {
for _, k := range keys {
if k.Type == "ssh-ed25519" {
return k.SHA256
}
}
if len(keys) > 0 {
return keys[0].SHA256
}
return ""
}
func shortFP(hexFP string) string {
if len(hexFP) < 16 {
return hexFP
}
return strings.ToUpper(hexFP[:8] + ":" + hexFP[8:16] + "…")
}

147
checker/types.go Normal file
View file

@ -0,0 +1,147 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package checker implements an SSH server security checker for
// happyDomain. It probes each SSH endpoint associated with an
// abstract.Server service and produces a structured report covering
// reachability, banner/version posture, algorithm negotiation
// (KEX/HostKey/Cipher/MAC/Compression), authentication method exposure
// and SSHFP host-key fingerprint validation.
package checker
import "time"
// ObservationKeySSH is the observation key this checker writes.
const ObservationKeySSH = "ssh"
// Option ids on CheckerOptions.
const (
OptionService = "service"
OptionPorts = "ports"
OptionProbeTimeoutMs = "probeTimeoutMs"
OptionIncludeAuthProbe = "includeAuthProbe"
)
// Defaults.
const (
DefaultSSHPort = 22
DefaultProbeTimeoutMs = 10000
MaxConcurrentProbes = 16
)
// Severity levels used in Issue.Severity.
const (
SeverityCrit = "crit"
SeverityWarn = "warn"
SeverityInfo = "info"
SeverityOK = "ok"
)
// SSHData is the full collected payload written under ObservationKeySSH.
type SSHData struct {
Domain string `json:"domain,omitempty"`
Endpoints []SSHProbe `json:"endpoints"`
SSHFP SSHFPSummary `json:"sshfp"`
CollectedAt time.Time `json:"collected_at"`
}
// SSHFPSummary captures the SSHFP records declared for the service and
// whether a usable chain (DNSSEC) is available.
type SSHFPSummary struct {
Records []SSHFPRecord `json:"records,omitempty"`
// Present indicates whether the service carries at least one SSHFP RR.
Present bool `json:"present"`
}
// SSHFPRecord is a single SSHFP record as declared in the zone.
type SSHFPRecord struct {
Algorithm uint8 `json:"algorithm"` // 1=RSA, 2=DSA, 3=ECDSA, 4=Ed25519
Type uint8 `json:"type"` // 1=SHA-1, 2=SHA-256
Fingerprint string `json:"fingerprint"` // hex, lowercase
}
// SSHProbe is the outcome of probing a single SSH endpoint.
type SSHProbe struct {
Host string `json:"host"`
Port uint16 `json:"port"`
Address string `json:"address"`
IP string `json:"ip,omitempty"`
IsIPv6 bool `json:"ipv6,omitempty"`
TCPConnected bool `json:"tcp_connected"`
// Banner is the SSH protocol banner (e.g. "SSH-2.0-OpenSSH_9.3p1").
Banner string `json:"banner,omitempty"`
SoftVer string `json:"software_version,omitempty"`
ProtoVer string `json:"protocol_version,omitempty"`
Vendor string `json:"vendor,omitempty"`
ElapsedMS int64 `json:"elapsed_ms,omitempty"`
Error string `json:"error,omitempty"`
// Algorithms negotiated by the server.
KEX []string `json:"kex_algorithms,omitempty"`
HostKey []string `json:"host_key_algorithms,omitempty"`
CiphersC2S []string `json:"ciphers_c2s,omitempty"`
CiphersS2C []string `json:"ciphers_s2c,omitempty"`
MACsC2S []string `json:"macs_c2s,omitempty"`
MACsS2C []string `json:"macs_s2c,omitempty"`
CompC2S []string `json:"compression_c2s,omitempty"`
CompS2C []string `json:"compression_s2c,omitempty"`
// Host keys observed during KEX. Multiple entries can appear if the
// server advertises several host-key types and we probe each in a
// second pass.
HostKeys []HostKeyInfo `json:"host_keys,omitempty"`
// Authentication methods advertised for a dummy "none" auth attempt.
AuthMethods []string `json:"auth_methods,omitempty"`
PasswordAuth bool `json:"password_auth,omitempty"`
KeyboardInteractive bool `json:"keyboard_interactive,omitempty"`
PublicKeyAuth bool `json:"public_key_auth,omitempty"`
AuthProbeAttempted bool `json:"auth_probe_attempted,omitempty"`
// Stage is the furthest probe stage the connection reached. One of
// "dial", "banner", "banner_write", "kexinit_read", "kexinit_parse",
// "kexinit_ok", "handshake_ok". Empty means the dial failed before
// even being attempted.
Stage string `json:"stage,omitempty"`
}
// HostKeyInfo captures an observed host key and its computed fingerprints.
type HostKeyInfo struct {
Type string `json:"type"` // e.g. "ssh-ed25519"
Bits int `json:"bits,omitempty"` // key size (bits)
SHA256 string `json:"sha256"` // hex fingerprint (lowercase, no colons)
SHA1 string `json:"sha1"` // hex fingerprint (lowercase, no colons)
SSHFPAlgo uint8 `json:"sshfp_algorithm"` // the SSHFP algorithm number matching this key type
SSHFPMatchSHA256 bool `json:"sshfp_match_sha256"`
SSHFPMatchSHA1 bool `json:"sshfp_match_sha1"`
}
// Issue is a single SSH finding surfaced to consumers.
type Issue struct {
Code string `json:"code"`
Severity string `json:"severity"`
Message string `json:"message,omitempty"`
Fix string `json:"fix,omitempty"`
// Endpoint is the "host:port" this issue applies to (empty for
// service-level issues such as missing SSHFP).
Endpoint string `json:"endpoint,omitempty"`
}

249
checker/vulns.go Normal file
View file

@ -0,0 +1,249 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package checker
import (
"fmt"
"regexp"
"strconv"
"strings"
)
// OpenSSH CVE database. The entries here are a curated subset of the
// ssh-audit vulnerability list focused on issues that are both
// remotely observable from a banner and serious enough to warrant
// surfacing in a periodic check.
//
// Versions are expressed using a semver-ish triple plus an optional
// "p" patch suffix, which mirrors OpenSSH's own numbering
// (e.g. 9.3p1 < 9.3p2 < 9.4p1). The matcher is conservative: when a
// banner can't be parsed into a version, we skip the match rather
// than over-flag.
type opensshVuln struct {
Code string
CVE string
Severity string
Title string
Description string
Fix string
AffectedRanges []opensshRange
}
type opensshRange struct {
MinInclusive string // "" means open-ended below
MaxExclusive string // "" means open-ended above
}
var opensshVulns = []opensshVuln{
{
Code: "cve_2024_6387_regreSSHion",
CVE: "CVE-2024-6387",
Severity: SeverityCrit,
Title: "regreSSHion (CVE-2024-6387)",
Description: "Signal-handler race in OpenSSH's sshd allows unauthenticated remote code execution as root on glibc-based systems.",
Fix: "Upgrade OpenSSH to 9.8p1 or later. If upgrading is not possible, set LoginGraceTime 0 in sshd_config as a mitigation (denial-of-service trade-off).",
AffectedRanges: []opensshRange{
// Regression reintroduced in 8.5p1; fixed in 9.8p1.
{MinInclusive: "8.5p1", MaxExclusive: "9.8p1"},
// The race also existed in < 4.4p1 (CVE-2006-5051 variant).
{MaxExclusive: "4.4p1"},
},
},
{
Code: "cve_2023_38408_agent",
CVE: "CVE-2023-38408",
Severity: SeverityCrit,
Title: "ssh-agent PKCS#11 provider RCE",
Description: "OpenSSH's forwarded ssh-agent in 5.5 through 9.3p1 can load and execute arbitrary shared libraries, enabling RCE if an attacker controls the forwarded agent.",
Fix: "Upgrade OpenSSH to 9.3p2 or later.",
AffectedRanges: []opensshRange{
{MinInclusive: "5.5", MaxExclusive: "9.3p2"},
},
},
{
Code: "cve_2023_48795_terrapin",
CVE: "CVE-2023-48795",
Severity: SeverityWarn,
Title: "Terrapin prefix truncation (CVE-2023-48795)",
Description: "A MITM can silently drop the first messages after KEX completes, potentially downgrading security features. Affects any SSH server supporting ChaCha20-Poly1305 or CBC-EtM without strict-KEX.",
Fix: "Upgrade OpenSSH to 9.6p1 or later (advertises kex-strict-s-v00@openssh.com).",
AffectedRanges: []opensshRange{
{MaxExclusive: "9.6p1"},
},
},
{
Code: "cve_2021_41617_agent_forward",
CVE: "CVE-2021-41617",
Severity: SeverityWarn,
Title: "sshd AuthorizedKeysCommand / AuthorizedPrincipalsCommand privilege drop flaw",
Description: "sshd from 6.2 to 8.8 fails to correctly drop supplementary groups when executing the AuthorizedKeysCommand/AuthorizedPrincipalsCommand helpers.",
Fix: "Upgrade OpenSSH to 8.8p1 or later.",
AffectedRanges: []opensshRange{
{MinInclusive: "6.2", MaxExclusive: "8.8p1"},
},
},
{
Code: "cve_2020_15778_scp",
CVE: "CVE-2020-15778",
Severity: SeverityWarn,
Title: "scp command-injection via shell quoting",
Description: "scp in OpenSSH through 8.3p1 does not sanitise filenames when copying files, enabling command injection on the destination via crafted names.",
Fix: "Upgrade OpenSSH to 8.4p1 or later; prefer sftp/rsync over scp.",
AffectedRanges: []opensshRange{
{MaxExclusive: "8.4p1"},
},
},
{
Code: "cve_2018_15473_user_enum",
CVE: "CVE-2018-15473",
Severity: SeverityWarn,
Title: "Username enumeration via timing",
Description: "OpenSSH through 7.7p1 allows remote username enumeration by timing the response to malformed authentication packets.",
Fix: "Upgrade OpenSSH to 7.8p1 or later.",
AffectedRanges: []opensshRange{
{MaxExclusive: "7.8p1"},
},
},
}
// analyseBannerSoftware flags a non-OpenSSH banner for operator
// awareness. No CVE match is attempted on unrecognised software.
func analyseBannerSoftware(addr, banner, software string) []Issue {
if banner == "" {
return nil
}
if parseOpenSSHVersion(software) != nil {
return nil
}
if looksLikeOpenSSH(software) {
return nil
}
return []Issue{{
Code: "non_openssh",
Severity: SeverityInfo,
Message: fmt.Sprintf("Server reports %q, not a recognised OpenSSH build. Verify the deployed software is maintained.", software),
Endpoint: addr,
}}
}
// analyseBannerVulns runs the banner through the OpenSSH CVE database
// and returns the matched issues. The banner parser is deliberately
// loose: a server running a vendor-patched OpenSSH (e.g.
// "OpenSSH_9.2p1 Debian-2+deb12u2") will still match the upstream
// version numbers, because distribution maintainers tend to backport
// fixes without changing the version string. Operators get to
// override these false positives at the UI layer, same as other
// checkers.
func analyseBannerVulns(addr, banner, software string) []Issue {
if banner == "" {
return nil
}
ver := parseOpenSSHVersion(software)
if ver == nil {
return nil
}
var issues []Issue
for _, v := range opensshVulns {
if rangesMatch(ver, v.AffectedRanges) {
issues = append(issues, Issue{
Code: v.Code,
Severity: v.Severity,
Message: fmt.Sprintf("%s: %s", v.Title, v.Description),
Fix: v.Fix,
Endpoint: addr,
})
}
}
return issues
}
// analyseBanner combines software-awareness and vulnerability matches.
// Retained as a convenience for the HTML report, which surfaces both
// concerns in a single "What to fix" list.
func analyseBanner(addr, banner, software string) []Issue {
out := analyseBannerSoftware(addr, banner, software)
out = append(out, analyseBannerVulns(addr, banner, software)...)
return out
}
func looksLikeOpenSSH(s string) bool {
return strings.HasPrefix(s, "OpenSSH_")
}
// opensshVersion captures a (major, minor, portable) tuple. Portable
// is 0 when the banner lists only a vanilla upstream version (which
// is rare). OpenSSH_9.3p1 → {9, 3, 1}.
type opensshVersion struct{ Major, Minor, Portable int }
var opensshBannerRe = regexp.MustCompile(`^OpenSSH_(\d+)\.(\d+)(?:p(\d+))?`)
func parseOpenSSHVersion(software string) *opensshVersion {
m := opensshBannerRe.FindStringSubmatch(software)
if m == nil {
return nil
}
v := &opensshVersion{}
v.Major, _ = strconv.Atoi(m[1])
v.Minor, _ = strconv.Atoi(m[2])
if m[3] != "" {
v.Portable, _ = strconv.Atoi(m[3])
}
return v
}
func less(a, b opensshVersion) bool {
if a.Major != b.Major {
return a.Major < b.Major
}
if a.Minor != b.Minor {
return a.Minor < b.Minor
}
return a.Portable < b.Portable
}
func rangesMatch(v *opensshVersion, ranges []opensshRange) bool {
for _, r := range ranges {
min, okMin := parseVersionString(r.MinInclusive)
max, okMax := parseVersionString(r.MaxExclusive)
if okMin && less(*v, min) {
continue
}
if okMax && !less(*v, max) {
continue
}
return true
}
return false
}
func parseVersionString(s string) (opensshVersion, bool) {
if s == "" {
return opensshVersion{}, false
}
// Reuse the banner regex by pretending we have a "OpenSSH_" prefix.
v := parseOpenSSHVersion("OpenSSH_" + s)
if v == nil {
return opensshVersion{}, false
}
return *v, true
}