Files
hydrogen/modules/ssh/ssh_security.py
2026-07-11 23:54:36 +07:00

230 lines
8.2 KiB
Python

import os
import shlex
import stat
from pathlib import Path
from core.base import BaseWorker
from core.schemas.results import AuditFindings, AuditResults
from core.schemas.status import AuditSeverity, AuditStatus
from .config import SSHModuleConfig
class SSHSecurityWorker(BaseWorker):
config_model = SSHModuleConfig
def __init__(self, config: SSHModuleConfig | None = None) -> None:
super().__init__(config)
self.module_config = config or SSHModuleConfig()
def run(self) -> AuditResults:
if not self.module_config.enabled:
return AuditResults(
status=AuditStatus.SKIPPED,
findings=[],
risk_level=0.0,
)
if self.module_config.test_failure:
findings = [
AuditFindings(
name="im fucking dumbass rragh",
description="im testing!.",
severity=AuditSeverity.CRITICAL,
)
]
return AuditResults(
status=AuditStatus.PASS if not findings else AuditStatus.FAIL,
findings=findings,
risk_level=self._calculate_risk(findings),
)
if os.name != "posix":
return AuditResults(
status=AuditStatus.SKIPPED,
findings=[],
risk_level=0.0,
)
sshd_config = Path("/etc/ssh/sshd_config")
if not sshd_config.exists():
return AuditResults(
status=AuditStatus.SKIPPED,
findings=[
AuditFindings(
name="sshd_config_not_found",
description="SSH server config /etc/ssh/sshd_config was not found.",
severity=AuditSeverity.LOW,
)
],
risk_level=0.1,
)
findings: list[AuditFindings] = []
resolved_config = self._read_merged_config(sshd_config)
findings.extend(self._check_config_directives(resolved_config))
findings.extend(self._check_config_permissions(sshd_config))
findings.extend(self._check_host_key_permissions(Path("/etc/ssh")))
return AuditResults(
status=AuditStatus.PASS if not findings else AuditStatus.FAIL,
findings=findings,
risk_level=self._calculate_risk(findings),
)
def _read_merged_config(self, root_config: Path) -> dict[str, str]:
config_files = [root_config]
include_dir = root_config.parent / "sshd_config.d"
if include_dir.exists() and include_dir.is_dir():
config_files.extend(sorted(include_dir.glob("*.conf")))
directives: dict[str, str] = {}
for config_file in config_files:
for raw_line in config_file.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
line = line.split("#", 1)[0].strip()
if not line:
continue
parts = shlex.split(line, comments=False, posix=True)
if len(parts) < 2: # noqa: PLR2004
continue
directive = parts[0].lower()
value = " ".join(parts[1:])
directives[directive] = value
return directives
def _check_config_directives(self, config: dict[str, str]) -> list[AuditFindings]:
findings: list[AuditFindings] = []
if config.get("permitrootlogin", "prohibit-password").lower() not in {
"no",
"forced-commands-only",
}:
findings.append(
AuditFindings(
name="permit_root_login_enabled",
description="Directive PermitRootLogin should be set to 'no' or 'forced-commands-only'.",
severity=AuditSeverity.CRITICAL,
)
)
if config.get("passwordauthentication", "yes").lower() != "no":
findings.append(
AuditFindings(
name="password_authentication_enabled",
description="Directive PasswordAuthentication should be disabled to prefer key-based access.",
severity=AuditSeverity.HIGH,
)
)
if config.get("pubkeyauthentication", "yes").lower() != "yes":
findings.append(
AuditFindings(
name="pubkey_authentication_disabled",
description="Directive PubkeyAuthentication should be enabled.",
severity=AuditSeverity.HIGH,
)
)
if config.get("x11forwarding", "no").lower() != "no":
findings.append(
AuditFindings(
name="x11_forwarding_enabled",
description="Directive X11Forwarding should usually be disabled on hardened servers.",
severity=AuditSeverity.MEDIUM,
)
)
max_auth_tries = config.get("maxauthtries", "6")
if max_auth_tries.isdigit() and int(max_auth_tries) > 4: # noqa: PLR2004
findings.append(
AuditFindings(
name="max_auth_tries_too_high",
description="Directive MaxAuthTries should be 4 or less.",
severity=AuditSeverity.MEDIUM,
)
)
if config.get("permitemptypasswords", "no").lower() != "no":
findings.append(
AuditFindings(
name="empty_passwords_permitted",
description="Directive PermitEmptyPasswords must be disabled.",
severity=AuditSeverity.CRITICAL,
)
)
return findings
def _check_config_permissions(self, config_path: Path) -> list[AuditFindings]:
findings: list[AuditFindings] = []
file_stat = config_path.stat()
mode = stat.S_IMODE(file_stat.st_mode)
if file_stat.st_uid != 0:
findings.append(
AuditFindings(
name="sshd_config_not_owned_by_root",
description="File /etc/ssh/sshd_config should be owned by root.",
severity=AuditSeverity.HIGH,
)
)
if mode & stat.S_IWGRP or mode & stat.S_IWOTH:
findings.append(
AuditFindings(
name="sshd_config_writable_by_non_root",
description="File /etc/ssh/sshd_config must not be writable by group or others.",
severity=AuditSeverity.HIGH,
)
)
return findings
def _check_host_key_permissions(self, ssh_dir: Path) -> list[AuditFindings]:
findings: list[AuditFindings] = []
for key_path in sorted(ssh_dir.glob("ssh_host_*_key")):
file_stat = key_path.stat()
mode = stat.S_IMODE(file_stat.st_mode)
if file_stat.st_uid != 0:
findings.append(
AuditFindings(
name=f"{key_path.name}_not_owned_by_root",
description=f"Private host key {key_path} should be owned by root.",
severity=AuditSeverity.HIGH,
)
)
if mode & (stat.S_IRWXG | stat.S_IRWXO):
findings.append(
AuditFindings(
name=f"{key_path.name}_permissions_too_open",
description=f"Private host key {key_path} should not be accessible to group or others.",
severity=AuditSeverity.CRITICAL,
)
)
return findings
def _calculate_risk(self, findings: list[AuditFindings]) -> float:
if not findings:
return 0.0
severity_scores = {
AuditSeverity.CRITICAL: 0.45,
AuditSeverity.HIGH: 0.3,
AuditSeverity.MEDIUM: 0.2,
AuditSeverity.LOW: 0.1,
}
risk = sum(severity_scores[finding.severity] for finding in findings)
return min(1.0, risk)