checker: enforce prober-as-observation, move all analysis to rules layer

This commit is contained in:
nemunaire 2026-05-15 17:05:53 +08:00
commit f77895dcab
12 changed files with 174 additions and 171 deletions

View file

@ -103,7 +103,7 @@ func (p *sshProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any
go func(ip string, port uint16) { go func(ip string, port uint16) {
defer wg.Done() defer wg.Done()
defer func() { <-sem }() defer func() { <-sem }()
probe := probeEndpoint(ctx, host, ip, port, timeout, includeAuthProbe, sshfp) probe := probeEndpoint(ctx, host, ip, port, timeout, includeAuthProbe)
log.Printf("checker-ssh: %s:%d banner=%q kex=%d hostkeys=%d stage=%s", 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) ip, port, probe.Banner, len(probe.KEX), len(probe.HostKeys), probe.Stage)
mu.Lock() mu.Lock()

View file

@ -24,9 +24,6 @@ package checker
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"net" "net"
"strconv" "strconv"
@ -40,16 +37,14 @@ import (
// triple. It never returns a Go error: every failure mode is recorded // triple. It never returns a Go error: every failure mode is recorded
// as a raw field on SSHProbe (Stage + Error). Severity / pass/fail // as a raw field on SSHProbe (Stage + Error). Severity / pass/fail
// classification is performed later by CheckRule.Evaluate, never here. // 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 { func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout time.Duration, includeAuthProbe bool) SSHProbe {
start := time.Now() start := time.Now()
addr := net.JoinHostPort(ip, strconv.Itoa(int(port))) addr := net.JoinHostPort(ip, strconv.Itoa(int(port)))
p := SSHProbe{ p := SSHProbe{
Host: host, Host: host,
Port: port, Port: port,
Address: addr, IP: net.ParseIP(ip),
IP: ip, Stage: "dial",
IsIPv6: strings.Contains(ip, ":"),
Stage: "dial",
} }
dialCtx, cancel := context.WithTimeout(ctx, timeout) dialCtx, cancel := context.WithTimeout(ctx, timeout)
@ -64,7 +59,6 @@ func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout ti
} }
defer conn.Close() defer conn.Close()
p.TCPConnected = true
p.Stage = "banner" p.Stage = "banner"
if deadline, ok := dialCtx.Deadline(); ok { if deadline, ok := dialCtx.Deadline(); ok {
_ = conn.SetDeadline(deadline) _ = conn.SetDeadline(deadline)
@ -122,9 +116,6 @@ func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout ti
// We hand off to Go's ssh package for the full handshake. HostKeyCallback lets us // 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. // capture each presented key without reimplementing DH/curve25519/kyber ourselves.
p.HostKeys = probeHostKeys(ctx, addr, host, srvKex.ServerHostKeyAlgorithms, timeout) p.HostKeys = probeHostKeys(ctx, addr, host, srvKex.ServerHostKeyAlgorithms, timeout)
for i := range p.HostKeys {
p.HostKeys[i].applySSHFP(sshfp)
}
if len(p.HostKeys) > 0 { if len(p.HostKeys) > 0 {
p.Stage = "handshake_ok" p.Stage = "handshake_ok"
} }
@ -156,7 +147,7 @@ func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout ti
func probeHostKeys(ctx context.Context, addr, host string, algos []string, timeout time.Duration) []HostKeyInfo { func probeHostKeys(ctx context.Context, addr, host string, algos []string, timeout time.Duration) []HostKeyInfo {
wantFamilies := pickHostKeyFamilies(algos) wantFamilies := pickHostKeyFamilies(algos)
seen := map[string]bool{} // by sha256 hex, dedupe across families seen := map[string]bool{} // by raw key bytes, dedupe across families
var out []HostKeyInfo var out []HostKeyInfo
for _, algo := range wantFamilies { for _, algo := range wantFamilies {
@ -165,10 +156,10 @@ func probeHostKeys(ctx context.Context, addr, host string, algos []string, timeo
continue continue
} }
info := describeHostKey(key) info := describeHostKey(key)
if seen[info.SHA256] { if seen[string(info.RawKey)] {
continue continue
} }
seen[info.SHA256] = true seen[string(info.RawKey)] = true
out = append(out, info) out = append(out, info)
} }
@ -299,50 +290,10 @@ func extractMethodsFromAuthError(err error) []string {
} }
func describeHostKey(key ssh.PublicKey) HostKeyInfo { func describeHostKey(key ssh.PublicKey) HostKeyInfo {
marshaled := key.Marshal() return HostKeyInfo{
sha2 := sha256.Sum256(marshaled)
sha1sum := sha1.Sum(marshaled)
info := HostKeyInfo{
Type: key.Type(), Type: key.Type(),
SHA256: hex.EncodeToString(sha2[:]), RawKey: key.Marshal(),
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 // parseBanner splits an "SSH-2.0-OpenSSH_9.3p1 Debian-1" banner into
@ -366,29 +317,6 @@ func parseBanner(b string) (proto, soft, vendor string) {
return 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 // errNoHostKey is returned by fetchHostKey when the callback never
// fired (e.g. transport-level error before the host key was received). // fired (e.g. transport-level error before the host key was received).
// Currently only used internally for readability. // Currently only used internally for readability.

View file

@ -89,19 +89,19 @@ type reportSSHFPRecord struct {
} }
type reportEndpoint struct { type reportEndpoint struct {
Address string Address string
Host string Host string
Port uint16 Port uint16
IsIPv6 bool IsIPv6 bool
TCPConnected bool DialFailed bool
Banner string Banner string
SoftwareVer string SoftwareVer string
Vendor string Vendor string
ElapsedMS int64 ElapsedMS int64
Error string Error string
StatusLabel string StatusLabel string
StatusClass string StatusClass string
AnyFail bool AnyFail bool
HostKeys []reportHostKey HostKeys []reportHostKey
AlgoTables []reportAlgoTable AlgoTables []reportAlgoTable
@ -239,11 +239,12 @@ func buildReportData(d *SSHData, states []sdk.CheckState) reportView {
matched := false matched := false
for _, ep := range d.Endpoints { for _, ep := range d.Endpoints {
for _, k := range ep.HostKeys { for _, k := range ep.HostKeys {
if k.SSHFPAlgo == rr.Algorithm { if sshfpAlgoForKeyType(k.Type) == rr.Algorithm {
if rr.Type == 2 && strings.EqualFold(rr.Fingerprint, k.SHA256) { sha1hex, sha256hex := hostKeyFingerprints(k)
if rr.Type == 2 && strings.EqualFold(rr.Fingerprint, sha256hex) {
matched = true matched = true
} }
if rr.Type == 1 && strings.EqualFold(rr.Fingerprint, k.SHA1) { if rr.Type == 1 && strings.EqualFold(rr.Fingerprint, sha1hex) {
matched = true matched = true
} }
} }
@ -264,24 +265,24 @@ func buildReportData(d *SSHData, states []sdk.CheckState) reportView {
for _, ep := range d.Endpoints { for _, ep := range d.Endpoints {
re := reportEndpoint{ re := reportEndpoint{
Address: ep.Address, Address: ep.Addr(),
Host: ep.Host, Host: ep.Host,
Port: ep.Port, Port: ep.Port,
IsIPv6: ep.IsIPv6, IsIPv6: ep.IP != nil && ep.IP.To4() == nil,
TCPConnected: ep.TCPConnected, DialFailed: ep.Stage == "dial",
Banner: ep.Banner, Banner: ep.Banner,
SoftwareVer: ep.SoftVer, SoftwareVer: ep.SoftVer,
Vendor: ep.Vendor, Vendor: ep.Vendor,
ElapsedMS: ep.ElapsedMS, ElapsedMS: ep.ElapsedMS,
Error: ep.Error, Error: ep.Error,
} }
if ep.IsIPv6 { if ep.IP != nil && ep.IP.To4() == nil {
v.AnyIPv6 = true v.AnyIPv6 = true
} else { } else {
v.AnyIPv4 = true v.AnyIPv4 = true
} }
perEpIssues := perEp[ep.Address] perEpIssues := perEp[ep.Addr()]
// Per-endpoint status label. // Per-endpoint status label.
epWorst := SeverityOK epWorst := SeverityOK
for _, f := range perEpIssues { for _, f := range perEpIssues {
@ -307,15 +308,18 @@ func buildReportData(d *SSHData, states []sdk.CheckState) reportView {
} }
for _, k := range ep.HostKeys { for _, k := range ep.HostKeys {
sha1hex, sha256hex := hostKeyFingerprints(k)
sha1Match, sha256Match := keyMatchesSSHFP(k, d.SSHFP)
algo := sshfpAlgoForKeyType(k.Type)
rh := reportHostKey{ rh := reportHostKey{
Type: k.Type, Type: k.Type,
Bits: k.Bits, Bits: hostKeyBits(k),
SHA256: k.SHA256, SHA256: sha256hex,
SHA1: k.SHA1, SHA1: sha1hex,
SSHFPMatched: sha256Match || sha1Match,
SSHFPFamily: sshfpAlgoName(algo),
SSHFPSnippet: fmt.Sprintf("%d 2 %s", algo, sha256hex),
} }
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.HostKeys = append(re.HostKeys, rh)
} }
@ -602,7 +606,7 @@ tr.info td:first-child { border-left: 3px solid #3b82f6; }
<dl class="kv"> <dl class="kv">
<dt>Host</dt><dd>{{.Host}}</dd> <dt>Host</dt><dd>{{.Host}}</dd>
<dt>IP</dt><dd><code>{{.Address}}</code>{{if .IsIPv6}} (IPv6){{end}}</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> <dt>TCP</dt><dd>{{if .DialFailed}}<span class="check-fail">&#10007; failed</span>{{else}}<span class="check-ok">&#10003; connected</span>{{end}}</dd>
{{if .SoftwareVer}}<dt>Version</dt><dd><code>{{.SoftwareVer}}</code>{{if .Vendor}} · <span class="note">{{.Vendor}}</span>{{end}}</dd>{{end}} {{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> <dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}} {{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}

View file

@ -28,8 +28,6 @@ import (
sdk "git.happydns.org/checker-sdk-go/checker" 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 { func Rules() []sdk.CheckRule {
return []sdk.CheckRule{ return []sdk.CheckRule{
&reachabilityRule{}, &reachabilityRule{},

View file

@ -54,7 +54,7 @@ func (r *algorithmFamilyRule) Evaluate(ctx context.Context, obs sdk.ObservationG
} }
var issues []Issue var issues []Issue
for _, ep := range eps { for _, ep := range eps {
issues = append(issues, analyseWeakAlgos(ep.Address, r.family, r.extract(&ep), r.table)...) issues = append(issues, analyseWeakAlgos(ep.Addr(), r.family, r.extract(&ep), r.table)...)
} }
if len(issues) == 0 { if len(issues) == 0 {
return []sdk.CheckState{passState(r.passCode, r.passMsg)} return []sdk.CheckState{passState(r.passCode, r.passMsg)}
@ -137,7 +137,7 @@ func (r *strictKexRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
} }
var issues []Issue var issues []Issue
for _, ep := range eps { for _, ep := range eps {
issues = append(issues, analyseStrictKex(ep.Address, ep.KEX)...) issues = append(issues, analyseStrictKex(ep.Addr(), ep.KEX)...)
} }
if len(issues) == 0 { if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.strict_kex.ok", "Every endpoint advertises the Terrapin mitigation marker.")} return []sdk.CheckState{passState("ssh.strict_kex.ok", "Every endpoint advertises the Terrapin mitigation marker.")}
@ -165,7 +165,7 @@ func (r *preauthCompressionRule) Evaluate(ctx context.Context, obs sdk.Observati
} }
var issues []Issue var issues []Issue
for _, ep := range eps { for _, ep := range eps {
issues = append(issues, analysePreauthCompression(ep.Address, ep.CompC2S)...) issues = append(issues, analysePreauthCompression(ep.Addr(), ep.CompC2S)...)
} }
if len(issues) == 0 { if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.preauth_compression.ok", "No endpoint offers pre-authentication zlib compression.")} return []sdk.CheckState{passState("ssh.preauth_compression.ok", "No endpoint offers pre-authentication zlib compression.")}

View file

@ -49,7 +49,7 @@ func (r *authMethodsRule) Evaluate(ctx context.Context, obs sdk.ObservationGette
continue continue
} }
probed = true probed = true
issues = append(issues, analyseAuthMethods(ep.Address, &ep)...) issues = append(issues, analyseAuthMethods(ep.Addr(), &ep)...)
} }
if !probed { if !probed {
return []sdk.CheckState{notTestedState("ssh.auth_methods.skipped", "Authentication-method enumeration disabled or not performed.")} return []sdk.CheckState{notTestedState("ssh.auth_methods.skipped", "Authentication-method enumeration disabled or not performed.")}

View file

@ -55,7 +55,7 @@ func (r *protocolVersionRule) Evaluate(ctx context.Context, obs sdk.ObservationG
states = append(states, sdk.CheckState{ states = append(states, sdk.CheckState{
Status: sdk.StatusCrit, Status: sdk.StatusCrit,
Code: "ssh_legacy_protocol", Code: "ssh_legacy_protocol",
Subject: ep.Address, Subject: ep.Addr(),
Message: fmt.Sprintf("Server advertises SSH protocol %q (banner %q). SSH-1 is obsolete and insecure.", ep.ProtoVer, ep.Banner), 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."}, Meta: map[string]any{"fix": "Disable SSH-1 support; run an sshd that only speaks SSH-2."},
}) })
@ -86,7 +86,7 @@ func (r *bannerSoftwareRule) Evaluate(ctx context.Context, obs sdk.ObservationGe
} }
var issues []Issue var issues []Issue
for _, ep := range data.Endpoints { for _, ep := range data.Endpoints {
issues = append(issues, analyseBannerSoftware(ep.Address, ep.Banner, ep.SoftVer)...) issues = append(issues, analyseBannerSoftware(ep.Addr(), ep.Banner, ep.SoftVer)...)
} }
if len(issues) == 0 { if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.banner_software.ok", "All probed servers advertise a recognised OpenSSH build.")} return []sdk.CheckState{passState("ssh.banner_software.ok", "All probed servers advertise a recognised OpenSSH build.")}
@ -113,7 +113,7 @@ func (r *knownVulnsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter
} }
var issues []Issue var issues []Issue
for _, ep := range data.Endpoints { for _, ep := range data.Endpoints {
issues = append(issues, analyseBannerVulns(ep.Address, ep.Banner, ep.SoftVer)...) issues = append(issues, analyseBannerVulns(ep.Addr(), ep.Banner, ep.SoftVer)...)
} }
if len(issues) == 0 { if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.known_vulnerabilities.ok", "No known CVE match against the advertised OpenSSH versions.")} return []sdk.CheckState{passState("ssh.known_vulnerabilities.ok", "No known CVE match against the advertised OpenSSH versions.")}

View file

@ -50,9 +50,9 @@ func (r *hostKeyStrengthRule) Evaluate(ctx context.Context, obs sdk.ObservationG
// Also flag endpoints that reached KEXINIT but failed to // Also flag endpoints that reached KEXINIT but failed to
// produce any host key: the handshake didn't complete. // produce any host key: the handshake didn't complete.
if len(ep.KEX) > 0 { if len(ep.KEX) > 0 {
issues = append(issues, analyseHandshakeHostKey(ep.Address, true, ep.HostKeys)...) issues = append(issues, analyseHandshakeHostKey(ep.Addr(), true, ep.HostKeys)...)
} }
issues = append(issues, analyseHostKeyStrength(ep.Address, ep.HostKeys)...) issues = append(issues, analyseHostKeyStrength(ep.Addr(), ep.HostKeys)...)
} }
if !anyKey && len(issues) == 0 { if !anyKey && len(issues) == 0 {
return []sdk.CheckState{notTestedState("ssh.host_key_strength.skipped", "No host key observed on any reachable endpoint.")} return []sdk.CheckState{notTestedState("ssh.host_key_strength.skipped", "No host key observed on any reachable endpoint.")}

View file

@ -49,10 +49,10 @@ func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGett
} }
var states []sdk.CheckState var states []sdk.CheckState
for _, ep := range data.Endpoints { for _, ep := range data.Endpoints {
if ep.TCPConnected { if ep.Stage != "dial" {
continue continue
} }
msg := "Cannot open TCP connection to " + ep.Address msg := "Cannot open TCP connection to " + ep.Addr()
if ep.Error != "" { if ep.Error != "" {
msg += ": " + ep.Error msg += ": " + ep.Error
} }
@ -60,7 +60,7 @@ func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGett
Status: sdk.StatusCrit, Status: sdk.StatusCrit,
Message: msg, Message: msg,
Code: "tcp_unreachable", Code: "tcp_unreachable",
Subject: ep.Address, Subject: ep.Addr(),
Meta: map[string]any{ Meta: map[string]any{
"fix": "Check DNS, firewall (allow tcp/" + strconv.Itoa(int(ep.Port)) + " from the internet), and that sshd is running.", "fix": "Check DNS, firewall (allow tcp/" + strconv.Itoa(int(ep.Port)) + " from the internet), and that sshd is running.",
}, },
@ -92,7 +92,7 @@ func (r *handshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
} }
var states []sdk.CheckState var states []sdk.CheckState
for _, ep := range data.Endpoints { for _, ep := range data.Endpoints {
if !ep.TCPConnected { if ep.Stage == "dial" {
continue continue
} }
switch ep.Stage { switch ep.Stage {
@ -100,29 +100,29 @@ func (r *handshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
states = append(states, sdk.CheckState{ states = append(states, sdk.CheckState{
Status: sdk.StatusCrit, Status: sdk.StatusCrit,
Code: "no_ssh_banner", Code: "no_ssh_banner",
Subject: ep.Address, Subject: ep.Addr(),
Message: fmt.Sprintf("Server on %s did not send an SSH-2.0 banner: %s", ep.Address, ep.Error), Message: fmt.Sprintf("Server on %s did not send an SSH-2.0 banner: %s", ep.Addr(), ep.Error),
Meta: map[string]any{"fix": "Check that an SSH daemon (not HTTP, mail, ...) listens on this port."}, Meta: map[string]any{"fix": "Check that an SSH daemon (not HTTP, mail, ...) listens on this port."},
}) })
case "banner_write": case "banner_write":
states = append(states, sdk.CheckState{ states = append(states, sdk.CheckState{
Status: sdk.StatusCrit, Status: sdk.StatusCrit,
Code: "banner_write_failed", Code: "banner_write_failed",
Subject: ep.Address, Subject: ep.Addr(),
Message: "Failed to send our client banner: " + ep.Error, Message: "Failed to send our client banner: " + ep.Error,
}) })
case "kexinit_read": case "kexinit_read":
states = append(states, sdk.CheckState{ states = append(states, sdk.CheckState{
Status: sdk.StatusCrit, Status: sdk.StatusCrit,
Code: "kexinit_read_failed", Code: "kexinit_read_failed",
Subject: ep.Address, Subject: ep.Addr(),
Message: "Server did not send KEXINIT after banner: " + ep.Error, Message: "Server did not send KEXINIT after banner: " + ep.Error,
}) })
case "kexinit_parse": case "kexinit_parse":
states = append(states, sdk.CheckState{ states = append(states, sdk.CheckState{
Status: sdk.StatusCrit, Status: sdk.StatusCrit,
Code: "kexinit_parse_failed", Code: "kexinit_parse_failed",
Subject: ep.Address, Subject: ep.Addr(),
Message: "Malformed KEXINIT packet: " + ep.Error, Message: "Malformed KEXINIT packet: " + ep.Error,
}) })
} }

View file

@ -48,7 +48,7 @@ func (r *sshfpAlignmentRule) Evaluate(ctx context.Context, obs sdk.ObservationGe
continue continue
} }
sawKey = true sawKey = true
issues = append(issues, analyseSSHFPAlignment(ep.Address, ep.HostKeys, data.SSHFP)...) issues = append(issues, analyseSSHFPAlignment(ep.Addr(), ep.HostKeys, data.SSHFP)...)
} }
if !sawKey { if !sawKey {
return []sdk.CheckState{notTestedState("ssh.sshfp_alignment.skipped", "No host key observed; SSHFP alignment cannot be assessed.")} return []sdk.CheckState{notTestedState("ssh.sshfp_alignment.skipped", "No host key observed; SSHFP alignment cannot be assessed.")}
@ -78,7 +78,7 @@ func (r *sshfpHashRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
} }
var issues []Issue var issues []Issue
for _, ep := range data.Endpoints { for _, ep := range data.Endpoints {
issues = append(issues, analyseSSHFPHashes(ep.Address, ep.HostKeys, data.SSHFP)...) issues = append(issues, analyseSSHFPHashes(ep.Addr(), ep.HostKeys, data.SSHFP)...)
} }
if len(issues) == 0 { if len(issues) == 0 {
return []sdk.CheckState{passState("ssh.sshfp_hash.ok", "SSHFP records include a SHA-256 (type 2) fingerprint.")} return []sdk.CheckState{passState("ssh.sshfp_hash.ok", "SSHFP records include a SHA-256 (type 2) fingerprint.")}

View file

@ -22,10 +22,55 @@
package checker package checker
import ( import (
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"strings" "strings"
"golang.org/x/crypto/ssh"
) )
// 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
}
// hostKeyFingerprints returns the SHA-1 and SHA-256 hex fingerprints of k.
func hostKeyFingerprints(k HostKeyInfo) (sha1hex, sha256hex string) {
sum1 := sha1.Sum(k.RawKey)
sum256 := sha256.Sum256(k.RawKey)
return hex.EncodeToString(sum1[:]), hex.EncodeToString(sum256[:])
}
// hostKeyBits returns the key size in bits (RSA/EC) or 0 if not applicable.
func hostKeyBits(k HostKeyInfo) int {
pub, err := ssh.ParsePublicKey(k.RawKey)
if err != nil {
return 0
}
cp, ok := pub.(ssh.CryptoPublicKey)
if !ok {
return 0
}
type bitSizer interface{ Size() int }
if bs, ok := cp.CryptoPublicKey().(bitSizer); ok {
return bs.Size() * 8
}
return 0
}
// analyseHandshakeHostKey flags an endpoint where the full handshake // analyseHandshakeHostKey flags an endpoint where the full handshake
// never yielded any host key. // never yielded any host key.
func analyseHandshakeHostKey(addr string, reached bool, keys []HostKeyInfo) []Issue { func analyseHandshakeHostKey(addr string, reached bool, keys []HostKeyInfo) []Issue {
@ -46,11 +91,15 @@ func analyseHandshakeHostKey(addr string, reached bool, keys []HostKeyInfo) []Is
func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue { func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue {
var issues []Issue var issues []Issue
for _, k := range keys { for _, k := range keys {
if k.SSHFPAlgo == 1 && k.Bits > 0 && k.Bits < 2048 { if sshfpAlgoForKeyType(k.Type) != 1 {
continue
}
bits := hostKeyBits(k)
if bits > 0 && bits < 2048 {
issues = append(issues, Issue{ issues = append(issues, Issue{
Code: "short_rsa_host_key", Code: "short_rsa_host_key",
Severity: SeverityCrit, Severity: SeverityCrit,
Message: fmt.Sprintf("RSA host key is %d bits; OpenSSH has rejected < 2048 bits since 8.2.", k.Bits), Message: fmt.Sprintf("RSA host key is %d bits; OpenSSH has rejected < 2048 bits since 8.2.", 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 ''", 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, Endpoint: addr,
}) })
@ -59,6 +108,29 @@ func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue {
return issues return issues
} }
// keyMatchesSSHFP reports whether k's fingerprints match any record in s.
func keyMatchesSSHFP(k HostKeyInfo, s SSHFPSummary) (sha1Match, sha256Match bool) {
algo := sshfpAlgoForKeyType(k.Type)
sha1hex, sha256hex := hostKeyFingerprints(k)
for _, rr := range s.Records {
if rr.Algorithm != algo {
continue
}
want := strings.ToLower(rr.Fingerprint)
switch rr.Type {
case 1:
if want == sha1hex {
sha1Match = true
}
case 2:
if want == sha256hex {
sha256Match = true
}
}
}
return
}
// analyseSSHFPAlignment returns per-key alignment issues: match, // analyseSSHFPAlignment returns per-key alignment issues: match,
// no coverage for a key family, or mismatch between DNS and server. // no coverage for a key family, or mismatch between DNS and server.
func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue { func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue {
@ -80,21 +152,18 @@ func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Is
coveredFamily[rr.Algorithm] = true coveredFamily[rr.Algorithm] = true
} }
for _, k := range keys { for _, k := range keys {
if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 { sha1Match, sha256Match := keyMatchesSSHFP(k, s)
issues = append(issues, Issue{ if sha256Match || sha1Match {
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 continue
} }
if !coveredFamily[k.SSHFPAlgo] { algo := sshfpAlgoForKeyType(k.Type)
_, sha256hex := hostKeyFingerprints(k)
if !coveredFamily[algo] {
issues = append(issues, Issue{ issues = append(issues, Issue{
Code: "sshfp_not_covered", Code: "sshfp_not_covered",
Severity: SeverityWarn, Severity: SeverityWarn,
Message: fmt.Sprintf("No SSHFP record covers host-key algorithm %s.", k.Type), 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), Fix: fmt.Sprintf("Add `IN SSHFP %d 2 %s` to the zone.", algo, sha256hex),
Endpoint: addr, Endpoint: addr,
}) })
continue continue
@ -103,7 +172,7 @@ func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Is
Code: "sshfp_mismatch", Code: "sshfp_mismatch",
Severity: SeverityCrit, 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), 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), Fix: fmt.Sprintf("Update the SSHFP record to the current fingerprint: `IN SSHFP %d 2 %s`, and investigate why DNS and the server disagree.", algo, sha256hex),
Endpoint: addr, Endpoint: addr,
}) })
} }
@ -119,7 +188,8 @@ func analyseSSHFPHashes(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue
} }
matchedAny := false matchedAny := false
for _, k := range keys { for _, k := range keys {
if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 { sha1Match, sha256Match := keyMatchesSSHFP(k, s)
if sha256Match || sha1Match {
matchedAny = true matchedAny = true
break break
} }
@ -157,11 +227,13 @@ func analyseHostKeys(addr string, keys []HostKeyInfo, s SSHFPSummary, reachedKex
func firstSHA256(keys []HostKeyInfo) string { func firstSHA256(keys []HostKeyInfo) string {
for _, k := range keys { for _, k := range keys {
if k.Type == "ssh-ed25519" { if k.Type == "ssh-ed25519" {
return k.SHA256 _, sha256hex := hostKeyFingerprints(k)
return sha256hex
} }
} }
if len(keys) > 0 { if len(keys) > 0 {
return keys[0].SHA256 _, sha256hex := hostKeyFingerprints(keys[0])
return sha256hex
} }
return "" return ""
} }

View file

@ -27,7 +27,11 @@
// and SSHFP host-key fingerprint validation. // and SSHFP host-key fingerprint validation.
package checker package checker
import "time" import (
"net"
"strconv"
"time"
)
// ObservationKeySSH is the observation key this checker writes. // ObservationKeySSH is the observation key this checker writes.
const ObservationKeySSH = "ssh" const ObservationKeySSH = "ssh"
@ -81,12 +85,9 @@ type SSHFPRecord struct {
// SSHProbe is the outcome of probing a single SSH endpoint. // SSHProbe is the outcome of probing a single SSH endpoint.
type SSHProbe struct { type SSHProbe struct {
Host string `json:"host"` Host string `json:"host"`
Port uint16 `json:"port"` Port uint16 `json:"port"`
Address string `json:"address"` IP net.IP `json:"ip,omitempty"`
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 is the SSH protocol banner (e.g. "SSH-2.0-OpenSSH_9.3p1").
Banner string `json:"banner,omitempty"` Banner string `json:"banner,omitempty"`
@ -125,15 +126,15 @@ type SSHProbe struct {
Stage string `json:"stage,omitempty"` Stage string `json:"stage,omitempty"`
} }
// HostKeyInfo captures an observed host key and its computed fingerprints. // Addr returns the "ip:port" dial string for this endpoint.
func (p SSHProbe) Addr() string {
return net.JoinHostPort(p.IP.String(), strconv.Itoa(int(p.Port)))
}
// HostKeyInfo captures an observed host key in its SSH wire format.
type HostKeyInfo struct { type HostKeyInfo struct {
Type string `json:"type"` // e.g. "ssh-ed25519" Type string `json:"type"` // e.g. "ssh-ed25519"
Bits int `json:"bits,omitempty"` // key size (bits) RawKey []byte `json:"key"` // SSH wire format (ssh.PublicKey.Marshal())
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. // Issue is a single SSH finding surfaced to consumers.