// 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 . // // 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 . 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 }