451 lines
16 KiB
Python
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])
|