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
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue