149 lines
5.4 KiB
Go
149 lines
5.4 KiB
Go
// 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 (
|
|
"net"
|
|
"strconv"
|
|
"time"
|
|
)
|
|
|
|
// ObservationKeySSH is the observation key this checker writes.
|
|
const ObservationKeySSH = "ssh"
|
|
|
|
// Option ids on CheckerOptions.
|
|
const (
|
|
OptionService = "service"
|
|
OptionDomainName = "domain_name"
|
|
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"`
|
|
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"`
|
|
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"`
|
|
}
|
|
|
|
// 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"
|
|
RawKey []byte `json:"key"` // SSH wire format (ssh.PublicKey.Marshal())
|
|
}
|
|
|
|
// 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"`
|
|
}
|