Initial commit
This commit is contained in:
commit
06036c89d9
29 changed files with 4891 additions and 0 deletions
285
checker/algorithms.go
Normal file
285
checker/algorithms.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue