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) {
defer wg.Done()
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",
ip, port, probe.Banner, len(probe.KEX), len(probe.HostKeys), probe.Stage)
mu.Lock()

View file

@ -24,9 +24,6 @@ package checker
import (
"bufio"
"context"
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"errors"
"net"
"strconv"
@ -40,16 +37,14 @@ import (
// 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 {
func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout time.Duration, includeAuthProbe bool) 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",
Host: host,
Port: port,
IP: net.ParseIP(ip),
Stage: "dial",
}
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()
p.TCPConnected = true
p.Stage = "banner"
if deadline, ok := dialCtx.Deadline(); ok {
_ = 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
// 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"
}
@ -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 {
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
for _, algo := range wantFamilies {
@ -165,10 +156,10 @@ func probeHostKeys(ctx context.Context, addr, host string, algos []string, timeo
continue
}
info := describeHostKey(key)
if seen[info.SHA256] {
if seen[string(info.RawKey)] {
continue
}
seen[info.SHA256] = true
seen[string(info.RawKey)] = true
out = append(out, info)
}
@ -299,50 +290,10 @@ func extractMethodsFromAuthError(err error) []string {
}
func describeHostKey(key ssh.PublicKey) HostKeyInfo {
marshaled := key.Marshal()
sha2 := sha256.Sum256(marshaled)
sha1sum := sha1.Sum(marshaled)
info := HostKeyInfo{
return HostKeyInfo{
Type: key.Type(),
SHA256: hex.EncodeToString(sha2[:]),
SHA1: hex.EncodeToString(sha1sum[:]),
RawKey: key.Marshal(),
}
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
@ -366,29 +317,6 @@ func parseBanner(b string) (proto, soft, vendor string) {
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.

View file

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

View file

@ -54,7 +54,7 @@ func (r *algorithmFamilyRule) Evaluate(ctx context.Context, obs sdk.ObservationG
}
var issues []Issue
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 {
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
for _, ep := range eps {
issues = append(issues, analyseStrictKex(ep.Address, ep.KEX)...)
issues = append(issues, analyseStrictKex(ep.Addr(), ep.KEX)...)
}
if len(issues) == 0 {
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
for _, ep := range eps {
issues = append(issues, analysePreauthCompression(ep.Address, ep.CompC2S)...)
issues = append(issues, analysePreauthCompression(ep.Addr(), ep.CompC2S)...)
}
if len(issues) == 0 {
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
}
probed = true
issues = append(issues, analyseAuthMethods(ep.Address, &ep)...)
issues = append(issues, analyseAuthMethods(ep.Addr(), &ep)...)
}
if !probed {
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{
Status: sdk.StatusCrit,
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),
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
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 {
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
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 {
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
// 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, 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 {
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
for _, ep := range data.Endpoints {
if ep.TCPConnected {
if ep.Stage != "dial" {
continue
}
msg := "Cannot open TCP connection to " + ep.Address
msg := "Cannot open TCP connection to " + ep.Addr()
if ep.Error != "" {
msg += ": " + ep.Error
}
@ -60,7 +60,7 @@ func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGett
Status: sdk.StatusCrit,
Message: msg,
Code: "tcp_unreachable",
Subject: ep.Address,
Subject: ep.Addr(),
Meta: map[string]any{
"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
for _, ep := range data.Endpoints {
if !ep.TCPConnected {
if ep.Stage == "dial" {
continue
}
switch ep.Stage {
@ -100,29 +100,29 @@ func (r *handshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter,
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),
Subject: ep.Addr(),
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."},
})
case "banner_write":
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "banner_write_failed",
Subject: ep.Address,
Subject: ep.Addr(),
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,
Subject: ep.Addr(),
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,
Subject: ep.Addr(),
Message: "Malformed KEXINIT packet: " + ep.Error,
})
}

View file

@ -48,7 +48,7 @@ func (r *sshfpAlignmentRule) Evaluate(ctx context.Context, obs sdk.ObservationGe
continue
}
sawKey = true
issues = append(issues, analyseSSHFPAlignment(ep.Address, ep.HostKeys, data.SSHFP)...)
issues = append(issues, analyseSSHFPAlignment(ep.Addr(), ep.HostKeys, data.SSHFP)...)
}
if !sawKey {
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
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 {
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
import (
"crypto/sha1"
"crypto/sha256"
"encoding/hex"
"fmt"
"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
// never yielded any host key.
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 {
var issues []Issue
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{
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),
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 ''",
Endpoint: addr,
})
@ -59,6 +108,29 @@ func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue {
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,
// no coverage for a key family, or mismatch between DNS and server.
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
}
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,
})
sha1Match, sha256Match := keyMatchesSSHFP(k, s)
if sha256Match || sha1Match {
continue
}
if !coveredFamily[k.SSHFPAlgo] {
algo := sshfpAlgoForKeyType(k.Type)
_, sha256hex := hostKeyFingerprints(k)
if !coveredFamily[algo] {
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),
Fix: fmt.Sprintf("Add `IN SSHFP %d 2 %s` to the zone.", algo, sha256hex),
Endpoint: addr,
})
continue
@ -103,7 +172,7 @@ func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Is
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),
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,
})
}
@ -119,7 +188,8 @@ func analyseSSHFPHashes(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue
}
matchedAny := false
for _, k := range keys {
if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 {
sha1Match, sha256Match := keyMatchesSSHFP(k, s)
if sha256Match || sha1Match {
matchedAny = true
break
}
@ -157,11 +227,13 @@ func analyseHostKeys(addr string, keys []HostKeyInfo, s SSHFPSummary, reachedKex
func firstSHA256(keys []HostKeyInfo) string {
for _, k := range keys {
if k.Type == "ssh-ed25519" {
return k.SHA256
_, sha256hex := hostKeyFingerprints(k)
return sha256hex
}
}
if len(keys) > 0 {
return keys[0].SHA256
_, sha256hex := hostKeyFingerprints(keys[0])
return sha256hex
}
return ""
}

View file

@ -27,7 +27,11 @@
// and SSHFP host-key fingerprint validation.
package checker
import "time"
import (
"net"
"strconv"
"time"
)
// ObservationKeySSH is the observation key this checker writes.
const ObservationKeySSH = "ssh"
@ -81,12 +85,9 @@ type SSHFPRecord struct {
// 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"`
Host string `json:"host"`
Port uint16 `json:"port"`
IP net.IP `json:"ip,omitempty"`
// Banner is the SSH protocol banner (e.g. "SSH-2.0-OpenSSH_9.3p1").
Banner string `json:"banner,omitempty"`
@ -125,15 +126,15 @@ type SSHProbe struct {
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 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"`
Type string `json:"type"` // e.g. "ssh-ed25519"
RawKey []byte `json:"key"` // SSH wire format (ssh.PublicKey.Marshal())
}
// Issue is a single SSH finding surfaced to consumers.