G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현 G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건) G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST) G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직 G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트 G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트 G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트 G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트 G-10: core/push_notify.py + routers/push.py + PushSubscription 모델 G-11: approvals 다중승인 (위임/서명/기한초과/마감연장) G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh 하네스: guardia-orchestrator 확장기능 Phase 반영 봇명령어: /sr /status /license /bulk 슬래시 명령어 추가 설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
366 lines
13 KiB
Python
366 lines
13 KiB
Python
"""
|
|
D-4: 보안 취약점 자동 스캔 엔진
|
|
|
|
기능:
|
|
1. 포트 스캔 (열린 포트 및 서비스 감지)
|
|
2. 기본 자격증명 체크 (기본 SSH 계정/패스워드 시도 금지 — 감지만)
|
|
3. 웹 취약점 패턴 스캔 (응답 헤더, 버전 노출 등)
|
|
4. 설정 취약점 감지 (root SSH 허용 여부, 평문 전송 등)
|
|
5. CVE 매핑 (서비스 버전 → 알려진 취약점)
|
|
6. Ollama sLLM 보조 분석 (내부 LLM — 외부 API 금지)
|
|
|
|
보안 제약:
|
|
- 인가된 대상에만 스캔 수행
|
|
- root 계정 활용 금지 (opsagent만)
|
|
- 스택트레이스 API 노출 금지
|
|
- 외부 취약점 DB 조회 금지 (내부망 전용)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import logging
|
|
import socket
|
|
from datetime import datetime
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── 취약점 심각도 ─────────────────────────────────────────────────────────────
|
|
SEVERITY = {
|
|
"CRITICAL": 4,
|
|
"HIGH": 3,
|
|
"MEDIUM": 2,
|
|
"LOW": 1,
|
|
"INFO": 0,
|
|
}
|
|
|
|
# ── 기본 포트 서비스 매핑 ──────────────────────────────────────────────────────
|
|
DEFAULT_PORT_SERVICES = {
|
|
21: "FTP",
|
|
22: "SSH",
|
|
23: "Telnet",
|
|
25: "SMTP",
|
|
53: "DNS",
|
|
80: "HTTP",
|
|
110: "POP3",
|
|
143: "IMAP",
|
|
443: "HTTPS",
|
|
445: "SMB",
|
|
1433: "MSSQL",
|
|
1521: "Oracle",
|
|
3306: "MySQL",
|
|
3389: "RDP",
|
|
5432: "PostgreSQL",
|
|
5900: "VNC",
|
|
6379: "Redis",
|
|
8080: "HTTP-Alt",
|
|
8443: "HTTPS-Alt",
|
|
27017: "MongoDB",
|
|
}
|
|
|
|
# ── 위험 포트 (즉시 경보) ──────────────────────────────────────────────────────
|
|
DANGER_PORTS = {23, 445, 3389, 5900} # Telnet, SMB, RDP, VNC
|
|
|
|
# ── 취약 서비스 버전 패턴 (내부 DB) ───────────────────────────────────────────
|
|
VULN_VERSION_PATTERNS = [
|
|
# (service, version_pattern, cve_id, severity, description)
|
|
("OpenSSH", "OpenSSH_7", "CVE-2023-38408", "HIGH", "ssh-add 원격 코드 실행"),
|
|
("OpenSSH", "OpenSSH_6", "CVE-2016-0778", "HIGH", "버퍼 오버플로"),
|
|
("Apache", "Apache/2.2", "CVE-2017-7679", "CRITICAL","mod_mime 버퍼 오버리드"),
|
|
("Apache", "Apache/2.4.49","CVE-2021-41773", "CRITICAL","경로 순회 취약점"),
|
|
("nginx", "nginx/1.1", "CVE-2013-2028", "HIGH", "청크 인코딩 스택 버퍼 오버플로"),
|
|
("Tomcat", "Apache Tomcat/8","CVE-2020-1938", "CRITICAL","AJP 파일 포함 취약점"),
|
|
("MySQL", "5.5.", "CVE-2016-6662", "HIGH", "설정 파일 덮어쓰기"),
|
|
("vsftpd", "vsftpd 2.3.4", "CVE-2011-2523", "CRITICAL","백도어"),
|
|
("Redis", "Redis", "CVE-2022-0543", "CRITICAL","샌드박스 탈출"),
|
|
]
|
|
|
|
# ── 보안 헤더 체크리스트 ──────────────────────────────────────────────────────
|
|
REQUIRED_SECURITY_HEADERS = [
|
|
"Strict-Transport-Security",
|
|
"X-Frame-Options",
|
|
"X-Content-Type-Options",
|
|
"Content-Security-Policy",
|
|
"X-XSS-Protection",
|
|
]
|
|
|
|
|
|
def _scan_port(host: str, port: int, timeout: float = 1.0) -> bool:
|
|
"""단일 포트 연결 테스트."""
|
|
try:
|
|
with socket.create_connection((host, port), timeout=timeout):
|
|
return True
|
|
except (socket.timeout, ConnectionRefusedError, OSError):
|
|
return False
|
|
|
|
|
|
def _grab_banner(host: str, port: int, timeout: float = 2.0) -> Optional[str]:
|
|
"""서비스 배너 그랩."""
|
|
try:
|
|
with socket.create_connection((host, port), timeout=timeout) as s:
|
|
s.settimeout(timeout)
|
|
try:
|
|
banner = s.recv(1024).decode("utf-8", errors="replace").strip()
|
|
return banner[:200] if banner else None
|
|
except Exception:
|
|
# HTTP 서비스는 먼저 요청 전송 필요
|
|
if port in (80, 8080, 443, 8443):
|
|
s.send(b"GET / HTTP/1.0\r\nHost: " + host.encode() + b"\r\n\r\n")
|
|
resp = s.recv(2048).decode("utf-8", errors="replace")
|
|
return resp[:200]
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def scan_ports(
|
|
host: str,
|
|
ports: Optional[List[int]] = None,
|
|
timeout: float = 1.0,
|
|
) -> List[Dict]:
|
|
"""
|
|
포트 스캔 수행.
|
|
Returns: 열린 포트 정보 리스트
|
|
"""
|
|
if ports is None:
|
|
ports = list(DEFAULT_PORT_SERVICES.keys())
|
|
|
|
open_ports = []
|
|
for port in ports:
|
|
if _scan_port(host, port, timeout):
|
|
service = DEFAULT_PORT_SERVICES.get(port, "Unknown")
|
|
banner = _grab_banner(host, port, timeout)
|
|
finding = {
|
|
"port": port,
|
|
"service": service,
|
|
"state": "open",
|
|
"banner": banner,
|
|
}
|
|
# 위험 포트 플래그
|
|
if port in DANGER_PORTS:
|
|
finding["risk"] = "HIGH"
|
|
finding["note"] = f"{service} 서비스 노출 — 즉시 차단 권고"
|
|
else:
|
|
finding["risk"] = "LOW"
|
|
open_ports.append(finding)
|
|
|
|
return open_ports
|
|
|
|
|
|
def check_version_vulns(banner: str, service: str = "") -> List[Dict]:
|
|
"""
|
|
서비스 배너/버전에서 알려진 취약점 패턴 매핑.
|
|
"""
|
|
findings = []
|
|
text = f"{service} {banner}".lower()
|
|
|
|
for svc, pattern, cve, severity, desc in VULN_VERSION_PATTERNS:
|
|
if pattern.lower() in text:
|
|
findings.append({
|
|
"cve": cve,
|
|
"severity": severity,
|
|
"service": svc,
|
|
"pattern": pattern,
|
|
"description": desc,
|
|
"banner": banner[:100],
|
|
})
|
|
|
|
return findings
|
|
|
|
|
|
def check_config_issues(host: str, open_ports: List[Dict]) -> List[Dict]:
|
|
"""
|
|
설정 취약점 감지 (포트 조합 기반).
|
|
"""
|
|
issues = []
|
|
port_set = {p["port"] for p in open_ports}
|
|
|
|
# Telnet 오픈 (평문 전송)
|
|
if 23 in port_set:
|
|
issues.append({
|
|
"check": "Telnet 활성화",
|
|
"severity": "HIGH",
|
|
"detail": "포트 23(Telnet)이 열려 있습니다. 평문 전송으로 자격증명 노출 위험.",
|
|
"recommend": "Telnet 비활성화 후 SSH로 대체",
|
|
})
|
|
|
|
# HTTP + HTTPS 동시 오픈 (HTTP→HTTPS 리다이렉트 미설정 가능)
|
|
if 80 in port_set and 443 in port_set:
|
|
issues.append({
|
|
"check": "HTTP/HTTPS 동시 노출",
|
|
"severity": "MEDIUM",
|
|
"detail": "HTTP(80)와 HTTPS(443)가 동시에 열려 있습니다. HTTP→HTTPS 강제 리다이렉트 확인 필요.",
|
|
"recommend": "HTTP 접근 시 301 리다이렉트 적용",
|
|
})
|
|
|
|
# RDP 오픈
|
|
if 3389 in port_set:
|
|
issues.append({
|
|
"check": "RDP 외부 노출",
|
|
"severity": "CRITICAL",
|
|
"detail": "포트 3389(RDP)가 열려 있습니다. 브루트포스 및 BlueKeep 공격 벡터.",
|
|
"recommend": "VPN 뒤로 이동 또는 방화벽 차단, NLA 강제 적용",
|
|
})
|
|
|
|
# Redis 오픈 (인증 없을 경우)
|
|
if 6379 in port_set:
|
|
issues.append({
|
|
"check": "Redis 외부 노출",
|
|
"severity": "CRITICAL",
|
|
"detail": "포트 6379(Redis)가 열려 있습니다. 무인증 접근 시 원격 코드 실행 가능.",
|
|
"recommend": "방화벽 차단 및 requirepass 설정",
|
|
})
|
|
|
|
# MongoDB 오픈
|
|
if 27017 in port_set:
|
|
issues.append({
|
|
"check": "MongoDB 외부 노출",
|
|
"severity": "HIGH",
|
|
"detail": "포트 27017(MongoDB)가 열려 있습니다. 무인증 접근 위험.",
|
|
"recommend": "방화벽 차단 및 auth 활성화",
|
|
})
|
|
|
|
# SMB 오픈 (EternalBlue 등)
|
|
if 445 in port_set:
|
|
issues.append({
|
|
"check": "SMB 외부 노출",
|
|
"severity": "CRITICAL",
|
|
"detail": "포트 445(SMB)가 열려 있습니다. EternalBlue/WannaCry 공격 벡터.",
|
|
"recommend": "방화벽 차단 (내부망 전용 허용)",
|
|
})
|
|
|
|
return issues
|
|
|
|
|
|
async def _llm_analyze_findings(findings_summary: str) -> Optional[str]:
|
|
"""
|
|
Ollama sLLM으로 취약점 결과 보완 분석.
|
|
실패 시 None 반환 (graceful fallback).
|
|
"""
|
|
try:
|
|
import httpx
|
|
prompt = (
|
|
"다음은 서버 보안 스캔 결과입니다. 각 취약점에 대한 간략한 조치 우선순위를 한국어로 3줄 이내로 요약해주세요.\n\n"
|
|
f"{findings_summary[:500]}"
|
|
)
|
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
resp = await client.post(
|
|
"http://localhost:11434/api/generate",
|
|
json={"model": "llama3", "prompt": prompt, "stream": False},
|
|
)
|
|
if resp.status_code == 200:
|
|
return resp.json().get("response", "")
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
async def run_vulnerability_scan(
|
|
host: str,
|
|
ports: Optional[List[int]] = None,
|
|
include_llm: bool = True,
|
|
timeout: float = 1.0,
|
|
) -> Dict:
|
|
"""
|
|
통합 취약점 스캔 실행.
|
|
Returns: {scan_id, host, started_at, completed_at, open_ports, vulnerabilities,
|
|
config_issues, severity_summary, risk_score, llm_analysis}
|
|
"""
|
|
scan_id = hashlib.sha256(
|
|
f"{host}:{datetime.utcnow().isoformat()}".encode()
|
|
).hexdigest()[:12]
|
|
|
|
started_at = datetime.utcnow()
|
|
logger.info("취약점 스캔 시작: %s (scan_id=%s)", host, scan_id)
|
|
|
|
# 1. 포트 스캔
|
|
open_ports = scan_ports(host, ports, timeout)
|
|
|
|
# 2. 버전 취약점 체크
|
|
vulnerabilities = []
|
|
for p in open_ports:
|
|
if p.get("banner"):
|
|
vulns = check_version_vulns(p["banner"], p.get("service", ""))
|
|
for v in vulns:
|
|
v["port"] = p["port"]
|
|
vulnerabilities.append(v)
|
|
|
|
# 3. 설정 취약점 체크
|
|
config_issues = check_config_issues(host, open_ports)
|
|
|
|
# 4. 심각도 집계
|
|
all_sev = [v["severity"] for v in vulnerabilities] + [c["severity"] for c in config_issues]
|
|
severity_summary = {s: all_sev.count(s) for s in ["CRITICAL", "HIGH", "MEDIUM", "LOW"]}
|
|
|
|
# 5. 위험 점수 (0~100)
|
|
risk_score = min(100, (
|
|
severity_summary.get("CRITICAL", 0) * 25 +
|
|
severity_summary.get("HIGH", 0) * 10 +
|
|
severity_summary.get("MEDIUM", 0) * 5 +
|
|
severity_summary.get("LOW", 0) * 1
|
|
))
|
|
|
|
completed_at = datetime.utcnow()
|
|
|
|
# 6. LLM 보조 분석 (optional)
|
|
llm_analysis = None
|
|
if include_llm and (vulnerabilities or config_issues):
|
|
summary_text = (
|
|
f"열린 포트: {[p['port'] for p in open_ports]}\n"
|
|
f"취약점: {[v['cve'] for v in vulnerabilities]}\n"
|
|
f"설정 이슈: {[c['check'] for c in config_issues]}"
|
|
)
|
|
llm_analysis = await _llm_analyze_findings(summary_text)
|
|
|
|
result = {
|
|
"scan_id": scan_id,
|
|
"host": host,
|
|
"started_at": started_at.isoformat(),
|
|
"completed_at": completed_at.isoformat(),
|
|
"duration_sec": (completed_at - started_at).total_seconds(),
|
|
"open_ports": open_ports,
|
|
"vulnerabilities": vulnerabilities,
|
|
"config_issues": config_issues,
|
|
"severity_summary": severity_summary,
|
|
"risk_score": risk_score,
|
|
"risk_level": (
|
|
"CRITICAL" if risk_score >= 75 else
|
|
"HIGH" if risk_score >= 50 else
|
|
"MEDIUM" if risk_score >= 25 else
|
|
"LOW"
|
|
),
|
|
"llm_analysis": llm_analysis,
|
|
"total_findings": len(vulnerabilities) + len(config_issues),
|
|
}
|
|
|
|
logger.info("스캔 완료: %s — 취약점 %d개, 설정이슈 %d개, 위험점수 %d",
|
|
host, len(vulnerabilities), len(config_issues), risk_score)
|
|
return result
|
|
|
|
|
|
def calculate_cvss_simplified(
|
|
attack_vector: str = "NETWORK",
|
|
complexity: str = "LOW",
|
|
privileges: str = "NONE",
|
|
impact: str = "HIGH",
|
|
) -> float:
|
|
"""
|
|
단순화된 CVSS 점수 계산 (0.0 ~ 10.0).
|
|
실제 CVSS v3.1의 근사 계산.
|
|
"""
|
|
av_score = {"NETWORK": 0.85, "ADJACENT": 0.62, "LOCAL": 0.55, "PHYSICAL": 0.2}.get(attack_vector, 0.85)
|
|
ac_score = {"LOW": 0.77, "HIGH": 0.44}.get(complexity, 0.77)
|
|
pr_score = {"NONE": 0.85, "LOW": 0.62, "HIGH": 0.27}.get(privileges, 0.85)
|
|
imp_score = {"NONE": 0.0, "LOW": 0.22, "MEDIUM": 0.5, "HIGH": 0.56}.get(impact, 0.56)
|
|
|
|
exploitability = 8.22 * av_score * ac_score * pr_score
|
|
iss = 1 - (1 - imp_score) ** 3
|
|
impact = 7.52 * iss
|
|
|
|
if impact == 0:
|
|
return 0.0
|
|
|
|
score = min(10.0, (impact + exploitability) * 1.08)
|
|
return round(score, 1)
|