checker: enforce prober-as-observation, move all analysis to rules layer
This commit is contained in:
parent
1e6254c289
commit
f77895dcab
12 changed files with 174 additions and 171 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -24,9 +24,6 @@ package checker
|
|||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
|
|
@ -40,15 +37,13 @@ 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, ":"),
|
||||
IP: net.ParseIP(ip),
|
||||
Stage: "dial",
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ type reportEndpoint struct {
|
|||
Host string
|
||||
Port uint16
|
||||
IsIPv6 bool
|
||||
TCPConnected bool
|
||||
DialFailed bool
|
||||
Banner string
|
||||
SoftwareVer string
|
||||
Vendor string
|
||||
|
|
@ -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,
|
||||
Address: ep.Addr(),
|
||||
Host: ep.Host,
|
||||
Port: ep.Port,
|
||||
IsIPv6: ep.IsIPv6,
|
||||
TCPConnected: ep.TCPConnected,
|
||||
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,
|
||||
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">✓ connected</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
||||
<dt>TCP</dt><dd>{{if .DialFailed}}<span class="check-fail">✗ failed</span>{{else}}<span class="check-ok">✓ 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}}
|
||||
|
|
|
|||
|
|
@ -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{},
|
||||
|
|
|
|||
|
|
@ -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.")}
|
||||
|
|
|
|||
|
|
@ -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.")}
|
||||
|
|
|
|||
|
|
@ -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.")}
|
||||
|
|
|
|||
|
|
@ -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.")}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.")}
|
||||
|
|
|
|||
102
checker/sshfp.go
102
checker/sshfp.go
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -83,10 +87,7 @@ type SSHFPRecord struct {
|
|||
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"`
|
||||
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"`
|
||||
RawKey []byte `json:"key"` // SSH wire format (ssh.PublicKey.Marshal())
|
||||
}
|
||||
|
||||
// Issue is a single SSH finding surfaced to consumers.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue