""" GUARDiA ITSM — SSH 실행 엔진 asyncssh 기반 비동기 원격 명령 실행. 보안 원칙: - root 직접 접속 금지 (ssh_user != root 강제) - 위험 명령어 패턴 차단 (BLOCKED_PATTERNS) - AES-256-GCM 자격증명 복호화 - 모든 실행 감사 로그 기록 - 서버 IP, 계정, 비밀번호 응답에 포함 금지 """ from __future__ import annotations import asyncio import base64 import logging import os import re from datetime import datetime from typing import Optional logger = logging.getLogger(__name__) # ── 위험 명령어 차단 패턴 ────────────────────────────────────────────────────── BLOCKED_PATTERNS: list[re.Pattern] = [ re.compile(p, re.IGNORECASE) for p in [ r"rm\s+-[rf]+\s+/", # rm -rf / r"rm\s+-[rf]+\s+\*", # rm -rf * r"mkfs(\.[a-z0-9]+)?\s", # mkfs r"dd\s+if=", # dd if= r">\s*/dev/(sd|hd|vd|nvme)", # 블록 디바이스 덮어쓰기 r">\s*/dev/zero", r"shutdown\s", # shutdown r"reboot(\s|$)", # reboot r"halt(\s|$)", # halt r"poweroff(\s|$)", # poweroff r"init\s+[06]", # init 0 / init 6 r"chmod\s+[0-9]*777\s+/", # chmod 777 / r"passwd\s+root", # root 비밀번호 변경 r"usermod\s+.*root", # root 계정 수정 r"visudo", # sudoers 편집 r"iptables\s+-F", # 방화벽 전체 삭제 r"firewall-cmd\s+--remove", # firewall-cmd 규칙 삭제 r"systemctl\s+(disable|mask)\s+(firewall|iptables|sshd)", r"setenforce\s+0", # SELinux 비활성화 r"rm\s+.*authorized_keys", # SSH 키 삭제 r"echo\s+.*>+\s*/etc/shadow", # shadow 파일 덮어쓰기 r"curl\s+.*\|\s*(bash|sh)", # curl | bash 패턴 r"wget\s+.*-O\s*-\s*\|", # wget | 파이프 r"base64\s+-d.*\|.*sh", # base64 decode | sh r"python.*exec\(", # 동적 코드 실행 r":(){ :|:& };:", # fork bomb ] ] def _validate_command(cmd: str) -> tuple[bool, str]: """ 명령어 안전성 검증. Returns: (is_safe, reason) """ for pattern in BLOCKED_PATTERNS: if pattern.search(cmd): return False, f"차단된 명령어 패턴: {pattern.pattern}" # 다중 명령어 내 각 부분도 검사 for part in re.split(r"[;&|]+", cmd): part = part.strip() for pattern in BLOCKED_PATTERNS: if pattern.search(part): return False, f"차단된 명령어 패턴 (조합): {pattern.pattern}" return True, "" def _decrypt_password(enc_b64: str) -> str: """ AES-256-GCM 암호화된 비밀번호 복호화. 형식: base64(nonce[12] + ciphertext + tag[16]) """ try: from cryptography.hazmat.primitives.ciphers.aead import AESGCM key_b64 = os.environ.get("AES_SECRET_KEY", "") if not key_b64: raise ValueError("AES_SECRET_KEY 환경변수 미설정") key = base64.b64decode(key_b64) data = base64.b64decode(enc_b64) nonce, ct = data[:12], data[12:] return AESGCM(key).decrypt(nonce, ct, None).decode() except Exception as e: logger.error("비밀번호 복호화 실패: %s", str(e)[:50]) raise RuntimeError("자격증명 복호화 실패") from e class SSHResult: """SSH 실행 결과 (보안 필드 미포함).""" def __init__(self, success: bool, stdout: str, stderr: str, exit_code: int, elapsed: float, error: str = ""): self.success = success self.stdout = stdout[:10000] # 최대 10KB self.stderr = stderr[:2000] # 최대 2KB self.exit_code = exit_code self.elapsed = round(elapsed, 2) self.error = error # 연결/실행 오류 요약 def to_dict(self) -> dict: return { "success": self.success, "stdout": self.stdout, "stderr": self.stderr, "exit_code": self.exit_code, "elapsed": self.elapsed, "error": self.error, } async def exec_command( server_name: str, command: str, timeout: int = 300, *, db=None, actor: str = "GUARDiA-AI", sr_id: Optional[str] = None, ) -> SSHResult: """ 지정 서버에 SSH 접속 후 명령어 실행. Args: server_name: tb_server.server_name command: 실행할 셸 명령어 timeout: 실행 타임아웃 (초) db: AsyncSession (None 이면 새 세션 생성) actor: 감사 로그 기록자 sr_id: 연관 SR ID (감사 로그용) """ import asyncssh from sqlalchemy import select from database import SessionLocal from models import Server, AuditLog, compute_log_hash # ── 1. 명령어 안전성 검증 ────────────────────────────── is_safe, reason = _validate_command(command) if not is_safe: logger.warning("[SSH] 차단된 명령: %s — %s", command[:80], reason) await _write_audit_safe( db, sr_id, actor, "SSH_BLOCKED", f"차단: {reason} | CMD: {command[:100]}" ) return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error=f"보안 정책에 의해 차단된 명령입니다: {reason}" ) # ── 2. 서버 자격증명 조회 ────────────────────────────── own_db = db is None _db = SessionLocal() if own_db else db server_obj = None try: r = await _db.execute( select(Server).where(Server.server_name == server_name) ) server_obj = r.scalars().first() finally: if own_db: await _db.close() if not server_obj: return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error=f"서버를 찾을 수 없습니다: {server_name}" ) # root 직접 접속 차단 ssh_user = getattr(server_obj, "ssh_user", "") or "" if ssh_user.strip() == "root": await _write_audit_safe( db, sr_id, actor, "SSH_BLOCKED", f"root 직접 접속 차단: {server_name}" ) return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error="root 직접 SSH 접속은 보안 정책에 의해 금지됩니다." ) # 비밀번호 복호화 enc_pw = getattr(server_obj, "os_pw_enc", "") or "" try: password = _decrypt_password(enc_pw) if enc_pw else "" except RuntimeError as e: return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error=str(e) ) ip_addr = getattr(server_obj, "ip_addr", "") ssh_port = getattr(server_obj, "ssh_port", 22) or 22 # ── 3. 감사 로그 — 실행 시작 ───────────────────────── await _write_audit_safe( db, sr_id, actor, "SSH_EXEC_START", f"서버: {server_name} | CMD: {command[:200]}" ) # ── 4. asyncssh 실행 ───────────────────────────────── start = asyncio.get_event_loop().time() try: connect_timeout = int(os.environ.get("SSH_CONNECT_TIMEOUT", "10")) known_hosts = os.environ.get("SSH_KNOWN_HOSTS", None) conn_kwargs: dict = { "host": ip_addr, "port": ssh_port, "username": ssh_user, "connect_timeout": connect_timeout, } if password: conn_kwargs["password"] = password # 운영환경: known_hosts 검증 / 개발환경: 비활성화 if known_hosts: conn_kwargs["known_hosts"] = known_hosts else: conn_kwargs["known_hosts"] = None # 개발/테스트용 async with asyncssh.connect(**conn_kwargs) as conn: result = await asyncio.wait_for( conn.run(command, check=False), timeout=timeout ) elapsed = asyncio.get_event_loop().time() - start stdout = (result.stdout or "").strip() stderr = (result.stderr or "").strip() exit_code = result.exit_status or 0 success = (exit_code == 0) # ── 5. 감사 로그 — 실행 완료 ───────────────────── summary = stdout[:200] if success else stderr[:200] await _write_audit_safe( db, sr_id, actor, "SSH_EXEC_DONE", f"서버: {server_name} | exit={exit_code} | {summary}" ) return SSHResult( success=success, stdout=stdout, stderr=stderr, exit_code=exit_code, elapsed=elapsed, ) except asyncio.TimeoutError: elapsed = asyncio.get_event_loop().time() - start await _write_audit_safe( db, sr_id, actor, "SSH_EXEC_TIMEOUT", f"서버: {server_name} | timeout={timeout}s" ) return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=elapsed, error=f"명령 실행 타임아웃 ({timeout}초)" ) except Exception as e: elapsed = asyncio.get_event_loop().time() - start # 에러 메시지에서 IP/계정 제거 후 로깅 safe_err = _sanitize_error(str(e)) logger.error("[SSH] 실행 오류 — 서버: %s | %s", server_name, safe_err) await _write_audit_safe( db, sr_id, actor, "SSH_EXEC_ERROR", f"서버: {server_name} | 오류: {safe_err}" ) return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=elapsed, error=f"SSH 연결/실행 오류 (서버 관리자에게 문의)" ) async def exec_script( server_name: str, script_path: str, env_vars: Optional[dict] = None, timeout: int = 600, *, db=None, actor: str = "GUARDiA-AI", sr_id: Optional[str] = None, ) -> SSHResult: """ 지정 서버에서 SM 쉘 스크립트 실행. 서버에 스크립트를 전송 후 실행 — SCP + bash. """ import asyncssh from sqlalchemy import select from database import SessionLocal from models import Server # 스크립트 경로 안전성 검사 safe_path = os.path.realpath(script_path) scripts_root = os.path.realpath( os.path.join(os.path.dirname(__file__), "..", "scripts", "sm") ) if not safe_path.startswith(scripts_root): return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error="허용되지 않은 스크립트 경로입니다." ) if not os.path.isfile(safe_path): return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error=f"스크립트 파일이 존재하지 않습니다: {os.path.basename(script_path)}" ) # 서버 정보 조회 own_db = db is None _db = SessionLocal() if own_db else db server_obj = None try: r = await _db.execute( select(Server).where(Server.server_name == server_name) ) server_obj = r.scalars().first() finally: if own_db: await _db.close() if not server_obj: return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error=f"서버를 찾을 수 없습니다: {server_name}" ) ssh_user = getattr(server_obj, "ssh_user", "") or "" if ssh_user.strip() == "root": return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error="root 직접 SSH 접속은 보안 정책에 의해 금지됩니다." ) enc_pw = getattr(server_obj, "os_pw_enc", "") or "" ip_addr = getattr(server_obj, "ip_addr", "") or "" ssh_port = getattr(server_obj, "ssh_port", 22) or 22 try: password = _decrypt_password(enc_pw) if enc_pw else "" except RuntimeError as e: return SSHResult(success=False, stdout="", stderr="", exit_code=-1, elapsed=0.0, error=str(e)) connect_timeout = int(os.environ.get("SSH_CONNECT_TIMEOUT", "10")) known_hosts = os.environ.get("SSH_KNOWN_HOSTS", None) conn_kwargs: dict = { "host": ip_addr, "port": ssh_port, "username": ssh_user, "connect_timeout": connect_timeout, "known_hosts": known_hosts, } if password: conn_kwargs["password"] = password remote_tmp = f"/tmp/guardia_sm_{os.path.basename(script_path)}" env_str = " ".join(f"{k}={v}" for k, v in (env_vars or {}).items()) await _write_audit_safe( db, sr_id, actor, "SSH_SCRIPT_START", f"서버: {server_name} | 스크립트: {os.path.basename(script_path)}" ) start = asyncio.get_event_loop().time() try: async with asyncssh.connect(**conn_kwargs) as conn: # SCP 전송 async with conn.start_sftp_client() as sftp: await sftp.put(safe_path, remote_tmp) # 실행 권한 + 실행 run_cmd = f"chmod +x {remote_tmp} && {env_str} bash {remote_tmp}; rm -f {remote_tmp}" result = await asyncio.wait_for( conn.run(run_cmd, check=False), timeout=timeout ) elapsed = asyncio.get_event_loop().time() - start stdout = (result.stdout or "").strip() stderr = (result.stderr or "").strip() exit_code = result.exit_status or 0 await _write_audit_safe( db, sr_id, actor, "SSH_SCRIPT_DONE", f"서버: {server_name} | script: {os.path.basename(script_path)} | exit={exit_code}" ) return SSHResult( success=(exit_code == 0), stdout=stdout, stderr=stderr, exit_code=exit_code, elapsed=elapsed ) except asyncio.TimeoutError: elapsed = asyncio.get_event_loop().time() - start return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=elapsed, error=f"스크립트 실행 타임아웃 ({timeout}초)" ) except Exception as e: elapsed = asyncio.get_event_loop().time() - start safe_err = _sanitize_error(str(e)) return SSHResult( success=False, stdout="", stderr="", exit_code=-1, elapsed=elapsed, error=f"SSH 스크립트 실행 오류: {safe_err}" ) def _sanitize_error(msg: str) -> str: """에러 메시지에서 IP/계정/비밀번호 패턴 제거.""" msg = re.sub(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", "", msg) msg = re.sub(r"password[=:\s]+\S+", "password=", msg, flags=re.IGNORECASE) msg = re.sub(r"user(?:name)?[=:\s]+\S+", "user=", msg, flags=re.IGNORECASE) return msg[:200] async def _write_audit_safe(db, sr_id, actor, action, detail): """감사 로그 안전 기록 — 독립 세션 사용.""" try: from database import SessionLocal from models import AuditLog, compute_log_hash from sqlalchemy import select async with SessionLocal() as _db: r = await _db.execute( select(AuditLog) .where(AuditLog.sr_id == sr_id) .order_by(AuditLog.id.desc()) .limit(1) ) last = r.scalars().first() prev_hash = last.log_hash if last else None ts = datetime.now().isoformat() log_hash = compute_log_hash(prev_hash, actor, action, detail, ts) _db.add(AuditLog( sr_id=sr_id, actor=actor, action=action, detail=detail, prev_hash=prev_hash, log_hash=log_hash )) await _db.commit() except Exception as e: logger.warning("[SSH] 감사 로그 기록 실패: %s", str(e)[:80])