checker-ssh/checker/algorithms.go

285 lines
12 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
import (
"fmt"
"strings"
)
// The tables below encode the safety verdict for each common SSH
// algorithm name. They are a condensed, hand-curated view of the
// ssh-audit algorithm database
// (https://github.com/jtesta/ssh-audit/blob/master/src/ssh_audit/ssh2_kexdb.py)
// reduced to the severities this checker surfaces.
//
// The logic we apply is: an algorithm advertised by the server is OK
// if it is in safeAlgos, suspicious (warn) if in weakAlgos, and
// critical if in brokenAlgos. Anything unknown is silently passed
// through: SSH is extensible, we prefer false negatives to noise.
type algoVerdict struct {
severity string // "crit", "warn", "info"
reason string // short human-readable reason
}
// KEX (key exchange) algorithms.
var kexAlgos = map[string]algoVerdict{
"curve25519-sha256": {},
"curve25519-sha256@libssh.org": {},
"sntrup761x25519-sha512@openssh.com": {severity: SeverityOK, reason: "hybrid post-quantum"},
"mlkem768x25519-sha256": {severity: SeverityOK, reason: "hybrid post-quantum (ML-KEM)"},
"ecdh-sha2-nistp256": {},
"ecdh-sha2-nistp384": {},
"ecdh-sha2-nistp521": {},
"diffie-hellman-group-exchange-sha256": {},
"diffie-hellman-group14-sha256": {},
"diffie-hellman-group16-sha512": {},
"diffie-hellman-group18-sha512": {},
"kex-strict-s-v00@openssh.com": {}, // not a real KEX, advertises "strict-kex" per CVE-2023-48795
// Deprecated / suspicious.
"diffie-hellman-group14-sha1": {severity: SeverityWarn, reason: "SHA-1 hash; upgrade to -sha256 variant"},
"diffie-hellman-group-exchange-sha1": {severity: SeverityWarn, reason: "SHA-1 hash; group-exchange with SHA-1 is discouraged"},
"rsa1024-sha1": {severity: SeverityCrit, reason: "1024-bit RSA KEX with SHA-1"},
"rsa2048-sha256": {severity: SeverityWarn, reason: "RSA key transport is deprecated"},
// Broken.
"diffie-hellman-group1-sha1": {severity: SeverityCrit, reason: "weak 1024-bit MODP group; vulnerable to Logjam"},
"gss-group1-sha1-toWM5Slw5Ew8Mqkay+al2g==": {severity: SeverityCrit, reason: "weak 1024-bit MODP group"},
}
// Server host-key algorithms.
var hostKeyAlgos = map[string]algoVerdict{
"ssh-ed25519": {},
"ssh-ed25519-cert-v01@openssh.com": {},
"ecdsa-sha2-nistp256": {},
"ecdsa-sha2-nistp384": {},
"ecdsa-sha2-nistp521": {},
"rsa-sha2-512": {},
"rsa-sha2-256": {},
"ssh-rsa": {severity: SeverityWarn, reason: "RSA with SHA-1 signatures (RFC 8332 marks as deprecated)"},
"ssh-dss": {severity: SeverityCrit, reason: "DSA is obsolete (weak 1024-bit signatures)"},
"ssh-rsa-cert-v01@openssh.com": {severity: SeverityWarn, reason: "RSA/SHA-1 certificate signatures are deprecated"},
"ssh-dss-cert-v01@openssh.com": {severity: SeverityCrit, reason: "DSA certificate signatures are obsolete"},
}
// Symmetric encryption algorithms (ciphers).
var cipherAlgos = map[string]algoVerdict{
"chacha20-poly1305@openssh.com": {},
"aes256-gcm@openssh.com": {},
"aes128-gcm@openssh.com": {},
"aes256-ctr": {},
"aes192-ctr": {},
"aes128-ctr": {},
"aes256-cbc": {severity: SeverityWarn, reason: "CBC mode is vulnerable to oracle attacks; prefer CTR or GCM"},
"aes192-cbc": {severity: SeverityWarn, reason: "CBC mode; prefer CTR or GCM"},
"aes128-cbc": {severity: SeverityWarn, reason: "CBC mode; prefer CTR or GCM"},
"rijndael-cbc@lysator.liu.se": {severity: SeverityWarn, reason: "legacy AES-CBC alias"},
"3des-cbc": {severity: SeverityCrit, reason: "Triple-DES is obsolete (Sweet32 birthday attack)"},
"blowfish-cbc": {severity: SeverityCrit, reason: "Blowfish-CBC; 64-bit block size (Sweet32)"},
"cast128-cbc": {severity: SeverityCrit, reason: "64-bit block; Sweet32"},
"arcfour": {severity: SeverityCrit, reason: "RC4 is broken"},
"arcfour128": {severity: SeverityCrit, reason: "RC4 is broken"},
"arcfour256": {severity: SeverityCrit, reason: "RC4 is broken"},
"none": {severity: SeverityCrit, reason: "No encryption"},
"des-cbc@ssh.com": {severity: SeverityCrit, reason: "DES is broken"},
}
// MAC algorithms.
var macAlgos = map[string]algoVerdict{
"hmac-sha2-512-etm@openssh.com": {},
"hmac-sha2-256-etm@openssh.com": {},
"umac-128-etm@openssh.com": {},
"hmac-sha2-512": {severity: SeverityWarn, reason: "non-ETM MAC; prefer -etm@openssh.com variants"},
"hmac-sha2-256": {severity: SeverityWarn, reason: "non-ETM MAC; prefer -etm@openssh.com variants"},
"umac-128@openssh.com": {severity: SeverityWarn, reason: "non-ETM MAC; prefer umac-128-etm@openssh.com"},
"umac-64@openssh.com": {severity: SeverityWarn, reason: "64-bit tag; prefer umac-128-etm@openssh.com"},
"hmac-sha1": {severity: SeverityWarn, reason: "SHA-1 MAC; prefer SHA-2 ETM"},
"hmac-sha1-etm@openssh.com": {severity: SeverityWarn, reason: "SHA-1 MAC (ETM); prefer SHA-2 ETM"},
"hmac-sha1-96": {severity: SeverityCrit, reason: "truncated SHA-1 MAC; forbidden"},
"hmac-md5": {severity: SeverityCrit, reason: "MD5 MAC; broken"},
"hmac-md5-96": {severity: SeverityCrit, reason: "truncated MD5 MAC; broken"},
"hmac-ripemd160": {severity: SeverityWarn, reason: "RIPEMD-160 MAC; seldom used"},
"none": {severity: SeverityCrit, reason: "No MAC"},
}
// Unknown names produce an empty verdict (no severity); callers treat that as "don't report"
// to avoid noise from SSH extensions we can't classify.
func verdictFor(table map[string]algoVerdict, name string) algoVerdict {
if v, ok := table[name]; ok {
return v
}
return algoVerdict{}
}
// analyseWeakAlgos emits one Issue per weak/broken algorithm in values
// using the verdict table.
func analyseWeakAlgos(addr, family string, values []string, table map[string]algoVerdict) []Issue {
var issues []Issue
for _, a := range values {
v := verdictFor(table, a)
if v.severity == "" || v.severity == SeverityOK {
continue
}
issues = append(issues, Issue{
Code: fmt.Sprintf("weak_%s", family),
Severity: v.severity,
Message: fmt.Sprintf("%s algorithm %q is %s", family, a, v.reason),
Fix: fixForFamily(family),
Endpoint: addr,
})
}
return issues
}
// analyseStrictKex flags the absence of the Terrapin mitigation marker
// (CVE-2023-48795): any modern sshd advertises kex-strict-s-v00@openssh.com
// alongside its KEX algorithms when the patched transport is available.
func analyseStrictKex(addr string, kex []string) []Issue {
if len(kex) == 0 || contains(kex, "kex-strict-s-v00@openssh.com") {
return nil
}
return []Issue{{
Code: "missing_strict_kex",
Severity: SeverityWarn,
Message: "Server does not advertise strict-KEX (CVE-2023-48795 \"Terrapin\"). Upgrade OpenSSH to 9.6 or later.",
Fix: "Upgrade sshd; no client-side fix mitigates this server-side gap.",
Endpoint: addr,
}}
}
// analysePreauthCompression flags servers that offer pre-authentication
// zlib compression. Many setups use zlib@openssh.com safely post-auth.
func analysePreauthCompression(addr string, comp []string) []Issue {
for _, c := range comp {
if c == "zlib" {
return []Issue{{
Code: "preauth_compression",
Severity: SeverityInfo,
Message: "Server offers pre-authentication zlib compression. Prefer zlib@openssh.com which kicks in only after auth.",
Endpoint: addr,
}}
}
}
return nil
}
// analyseAlgorithms is a convenience used by the HTML report: returns
// every algorithm-related issue for a single endpoint.
func analyseAlgorithms(addr string, p *SSHProbe) []Issue {
var issues []Issue
issues = append(issues, analyseWeakAlgos(addr, "kex", p.KEX, kexAlgos)...)
issues = append(issues, analyseWeakAlgos(addr, "hostkey_alg", p.HostKey, hostKeyAlgos)...)
issues = append(issues, analyseWeakAlgos(addr, "cipher", uniqueMerge(p.CiphersC2S, p.CiphersS2C), cipherAlgos)...)
issues = append(issues, analyseWeakAlgos(addr, "mac", uniqueMerge(p.MACsC2S, p.MACsS2C), macAlgos)...)
issues = append(issues, analyseStrictKex(addr, p.KEX)...)
issues = append(issues, analysePreauthCompression(addr, p.CompC2S)...)
return issues
}
// fixForFamily returns a short generic hint to pair with the
// per-algorithm warning. The HTML report shows algorithm-specific
// verdicts alongside this so operators know what to edit.
func fixForFamily(family string) string {
switch family {
case "kex":
return "Edit /etc/ssh/sshd_config KexAlgorithms= to list only modern algorithms (curve25519-sha256, ecdh-sha2-nistp256, diffie-hellman-group16-sha512)."
case "hostkey_alg":
return "Set HostKeyAlgorithms= to ssh-ed25519,rsa-sha2-512,rsa-sha2-256 (drop ssh-rsa and ssh-dss)."
case "cipher":
return "Restrict Ciphers= to chacha20-poly1305@openssh.com, aes256-gcm@openssh.com, aes128-gcm@openssh.com and the -ctr variants."
case "mac":
return "Restrict MACs= to the -etm@openssh.com variants (hmac-sha2-256-etm, hmac-sha2-512-etm, umac-128-etm)."
}
return ""
}
// uniqueMerge returns the union of a and b, preserving first-seen order.
func uniqueMerge(a, b []string) []string {
seen := map[string]bool{}
var out []string
for _, v := range a {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
for _, v := range b {
if !seen[v] {
seen[v] = true
out = append(out, v)
}
}
return out
}
func contains(haystack []string, needle string) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
// analyseAuthMethods flags the classic "password auth exposed to the
// internet" antipattern and complementary findings.
func analyseAuthMethods(addr string, p *SSHProbe) []Issue {
var issues []Issue
if p.AuthMethods == nil {
return nil
}
if p.PasswordAuth {
issues = append(issues, Issue{
Code: "password_auth_enabled",
Severity: SeverityWarn,
Message: "Server accepts password authentication. Combined with publicly exposed sshd this is the single largest source of compromises.",
Fix: "Set PasswordAuthentication no in /etc/ssh/sshd_config and rely on publickey (or keyboard-interactive + hardware MFA).",
Endpoint: addr,
})
}
if !p.PublicKeyAuth && len(p.AuthMethods) > 0 {
issues = append(issues, Issue{
Code: "no_publickey_auth",
Severity: SeverityWarn,
Message: "Server does not advertise public-key authentication. This is unusual for production SSH deployments.",
Fix: "Set PubkeyAuthentication yes in sshd_config.",
Endpoint: addr,
})
}
return issues
}
// lowerAll returns a copy of s with every element lowercased. Used by
// callers that want a case-insensitive membership check.
func lowerAll(s []string) []string {
out := make([]string, len(s))
for i, v := range s {
out[i] = strings.ToLower(v)
}
return out
}