guardia-itsm/core/ssh_exec.py
2026-05-30 23:02:43 +09:00

451 lines
16 KiB
Python

"""
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", "<IP>", msg)
msg = re.sub(r"password[=:\s]+\S+", "password=<hidden>", msg, flags=re.IGNORECASE)
msg = re.sub(r"user(?:name)?[=:\s]+\S+", "user=<hidden>", 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])