285 lines
12 KiB
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
|
|
}
|