From 0ddf95f2a221d6480edaf2fab6fd702e92e195e0 Mon Sep 17 00:00:00 2001 From: pearseona Date: Sun, 2 Aug 2026 16:51:39 +0900 Subject: [PATCH] chore: configure secure production environment for AI worker --- .dockerignore | 12 ++- .env.example | 13 ++++ .env.prod.example | 27 +++++++ .gitignore | 9 ++- Dockerfile | 16 ++-- README.md | 45 +++++++++++ app/analysis/router.py | 16 ++-- app/analysis/service.py | 16 ++-- app/analysis/text/gemini_analyzer.py | 25 +++--- app/analysis/text/naive_bayes_analyzer.py | 27 +++---- app/analysis/url/analyzer.py | 23 +++--- app/analysis/url/tracker.py | 32 ++++++-- app/chat/router.py | 14 +++- app/chat/service.py | 11 +-- app/core/config.py | 63 ++++++++++++++- .../google_safe_browsing/client.py | 13 ++-- app/infrastructure/mock_provider.py | 2 +- app/infrastructure/virustotal/client.py | 22 +++--- app/main.py | 22 ++++++ docker-compose.prod.yml | 20 +++++ requirements.txt | 4 +- tests/core/__init__.py | 1 + tests/core/test_config.py | 77 +++++++++++++++++++ 23 files changed, 408 insertions(+), 102 deletions(-) create mode 100644 .env.example create mode 100644 .env.prod.example create mode 100644 docker-compose.prod.yml create mode 100644 tests/core/__init__.py create mode 100644 tests/core/test_config.py diff --git a/.dockerignore b/.dockerignore index 70e2880..1fe3b45 100644 --- a/.dockerignore +++ b/.dockerignore @@ -14,4 +14,14 @@ tests requirements-dev.txt *.log -.DS_Store \ No newline at end of file +.DS_Store + +secrets +data_science/** +!data_science/SMSModel/ +!data_science/SMSModel/phishing_model_artifact.pkl +!data_science/SMSModel/phishing_vectorizer.pkl +*.ipynb +*.csv +*.png +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8050c4b --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +ENV=local + +GEMINI_API_KEY= +GEMINI_MODEL=gemini-flash-latest +VIRUSTOTAL_API_KEY= +GOOGLE_SAFE_BROWSING_API_KEY= +MOCK_SECURITY_API=false + +RABBITMQ_URL=amqp://safefam:safefam-local@localhost:5672/ +RABBITMQ_CONSUMER_ENABLED=false + +NAIVE_BAYES_MODEL_PATH=data_science/SMSModel/phishing_model_artifact.pkl +NAIVE_BAYES_VECTORIZER_PATH=data_science/SMSModel/phishing_vectorizer.pkl diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 0000000..914e281 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,27 @@ +ENV=prod +FASTAPI_IMAGE_TAG= + +GEMINI_API_KEY= +GEMINI_MODEL=gemini-flash-latest +VIRUSTOTAL_API_KEY= +GOOGLE_SAFE_BROWSING_API_KEY= + +MOCK_SECURITY_API=false + +RABBITMQ_URL= +RABBITMQ_ANALYSIS_EXCHANGE=safefam.analysis +RABBITMQ_ANALYSIS_REQUEST_QUEUE=safefam.analysis.requested.q +RABBITMQ_ANALYSIS_REQUEST_ROUTING_KEY=analysis.requested.v1 +RABBITMQ_ANALYSIS_COMPLETED_ROUTING_KEY=analysis.completed.v1 +RABBITMQ_ANALYSIS_PARTIAL_ROUTING_KEY=analysis.partial.v1 +RABBITMQ_ANALYSIS_FAILED_ROUTING_KEY=analysis.failed.v1 +RABBITMQ_ANALYSIS_DLQ=safefam.analysis.requested.dlq +RABBITMQ_ANALYSIS_DLQ_ROUTING_KEY=analysis.requested.dead.v1 +RABBITMQ_PREFETCH_COUNT=1 +RABBITMQ_CONSUMER_ENABLED=true +RABBITMQ_PUBLISH_TIMEOUT_SECONDS=5 +RABBITMQ_SHUTDOWN_TIMEOUT_SECONDS=30 +RABBITMQ_REQUEUE_BACKOFF_SECONDS=1 + +NAIVE_BAYES_MODEL_PATH=/app/models/phishing_model_artifact.pkl +NAIVE_BAYES_VECTORIZER_PATH=/app/models/phishing_vectorizer.pkl diff --git a/.gitignore b/.gitignore index 62d2327..2ad8439 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,11 @@ __pycache__/ .pytest_cache/ .venv/ .claude/settings.local.json -.env \ No newline at end of file +.env +.env.* +!.env.example +!.env.prod.example + +secrets/ +*.pem +*.key diff --git a/Dockerfile b/Dockerfile index 7a74c8c..6845684 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,24 @@ FROM python:3.11-slim -WORKDIR /workspace +WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 +ENV NAIVE_BAYES_MODEL_PATH=/app/models/phishing_model_artifact.pkl +ENV NAIVE_BAYES_VECTORIZER_PATH=/app/models/phishing_vectorizer.pkl COPY requirements.txt . - RUN pip install --no-cache-dir -r requirements.txt COPY app app +COPY data_science/SMSModel/phishing_model_artifact.pkl models/phishing_model_artifact.pkl +COPY data_science/SMSModel/phishing_vectorizer.pkl models/phishing_vectorizer.pkl -RUN useradd --create-home --shell /usr/sbin/nologin safefam +RUN useradd --create-home --shell /usr/sbin/nologin safefam \ + && chown -R safefam:safefam /app USER safefam EXPOSE 8000 -HEALTHCHECK \ - --interval=30s \ - --timeout=5s \ - --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/', timeout=3)" - CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index ef9f2ab..74da425 100644 --- a/README.md +++ b/README.md @@ -67,3 +67,48 @@ docker compose up --build 서버가 켜지면 브라우저를 열고 아래 주소로 접속하여 정상 작동하는지 확인합니다. - **Swagger UI (API 문서)**: http://127.0.0.1:8000/docs + +## 운영 환경 + +운영 환경에서는 `docker-compose.prod.yml`과 Git Commit SHA로 고정된 이미지를 +사용합니다. `--reload`, 소스 코드 바인드 마운트, 호스트 포트 공개는 사용하지 +않습니다. + +```bash +cp .env.prod.example .env.runtime +docker compose -f docker-compose.prod.yml --env-file .env.runtime up -d +``` + +`.env.runtime`의 실제 값은 저장소에 커밋하지 않습니다. EC2 IAM Role로 AWS +Parameter Store의 `SecureString`을 조회하여 배포 시점에 생성합니다. + +### 운영 필수 Secret + +- `GEMINI_API_KEY` +- `VIRUSTOTAL_API_KEY` +- `GOOGLE_SAFE_BROWSING_API_KEY` +- `RABBITMQ_URL` + +권장 Parameter Store 경로는 다음과 같습니다. + +```text +/safefam/prod/ai/GEMINI_API_KEY +/safefam/prod/ai/VIRUSTOTAL_API_KEY +/safefam/prod/ai/GOOGLE_SAFE_BROWSING_API_KEY +``` + +운영에서 필수 Secret이 누락되거나 `MOCK_SECURITY_API=true`이면 애플리케이션은 +시작하지 않습니다. + +### 모델 파일 + +운영 이미지에는 아래 두 개의 검증된 학습 산출물만 포함합니다. + +```text +/app/models/phishing_model_artifact.pkl +/app/models/phishing_vectorizer.pkl +``` + +컨테이너 시작 시 두 파일이 없으면 애플리케이션이 즉시 실패합니다. Pickle은 +임의 파일을 실행할 위험이 있으므로 저장소에서 관리하는 신뢰된 산출물만 +사용해야 합니다. diff --git a/app/analysis/router.py b/app/analysis/router.py index 9880534..8e63372 100644 --- a/app/analysis/router.py +++ b/app/analysis/router.py @@ -21,13 +21,19 @@ async def analyze_smishing( analysis_service: SmishingAnalysisService = Depends(get_analysis_service) ) -> SmishingAnalysisResponse: - logger.info(f"[Router] 통합 스미싱 분석 마스터 파이프라인 진입: {payload.text[:15]}...") + logger.info( + "[Router] 통합 스미싱 분석 요청 수신. text_length=%d", + len(payload.text), + ) try: return await analysis_service.analyze_pipeline(payload.text) - except Exception as e: - logger.error(f"[Router] 스캔 처리 중 장애 발생: {str(e)}") + except Exception as exception: + logger.error( + "[Router] 스캔 처리 중 장애 발생. error_type=%s", + type(exception).__name__, + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"서버 내부 스캔 파이프라인 연산 중 오류: {str(e)}" - ) + detail="서버 내부 스캔 파이프라인 연산 중 오류가 발생했습니다.", + ) from exception diff --git a/app/analysis/service.py b/app/analysis/service.py index fc6a552..49ea212 100644 --- a/app/analysis/service.py +++ b/app/analysis/service.py @@ -101,8 +101,11 @@ async def url_track(): # 로컬 규칙 기반 트랙: 금융기관 DB 대조 + 금융 키워드 + 계좌/카드번호 패턴 + 도메인 룰(.ru 등) try: rule_result = self.rule_analyzer(text, traced_url) - except Exception: - logger.exception("[Analysis Service] 규칙 분석 중 오류 발생") + except Exception as exception: + logger.error( + "[Analysis Service] 규칙 분석 중 오류 발생. error_type=%s", + type(exception).__name__, + ) rule_result = { "rule_score": 0, "has_malicious_domain_pattern": False, @@ -189,8 +192,11 @@ async def url_track(): url_analysis=real_url_analysis, rule_analysis=rule_result ) - except Exception as e: - logger.error(f"파이프라인 에러: {str(e)}") + except Exception as exception: + logger.error( + "파이프라인 오류. error_type=%s", + type(exception).__name__, + ) # 파이프라인이 통째로 죽어 어떤 트랙도 실행되지 못한 경우, final_score=0/LOW를 # 반환하면 "분석 실패"가 "안전 확인됨"으로 읽혀 fail-open이 된다 (텍스트 트랙 # 양쪽 엔진이 동시에 실패한 경우를 막는 BOTH_ENGINES_UNAVAILABLE_FALLBACK_SCORE와 @@ -198,7 +204,7 @@ async def url_track(): # 최소 MEDIUM으로 강제한다. return SmishingAnalysisResponse( status="ERROR", - message=str(e), + message="분석 파이프라인 처리 중 오류가 발생했습니다.", final_score=RiskScoringEngine.PIPELINE_FAILURE_FALLBACK_SCORE, risk_grade=RiskGrade.MEDIUM, contribution_breakdown=ContributionBreakdown(llm=0, hybrid_url=0, rules=0), diff --git a/app/analysis/text/gemini_analyzer.py b/app/analysis/text/gemini_analyzer.py index 842e274..c983879 100644 --- a/app/analysis/text/gemini_analyzer.py +++ b/app/analysis/text/gemini_analyzer.py @@ -1,20 +1,17 @@ -import os import json import logging import httpx -from dotenv import load_dotenv from app.analysis.risk_policy import determine_text_risk_grade +from app.core.config import settings from app.infrastructure.gemini.client import GeminiClient -load_dotenv() - logger = logging.getLogger(__name__) -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") -GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-flash-latest") +GEMINI_API_KEY = settings.GEMINI_API_KEY +GEMINI_MODEL = settings.GEMINI_MODEL API_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent" -MOCK_ENABLED = os.getenv("MOCK_SECURITY_API", "False").lower() in ("true", "1", "t") +MOCK_ENABLED = settings.MOCK_SECURITY_API # Gemini에게 구조화된 JSON 응답을 강제하기 위한 응답 스키마 RESPONSE_SCHEMA = { @@ -174,10 +171,16 @@ async def analyze_text_with_gemini(text: str) -> dict: logger.error("Gemini API 요청 타임아웃 발생") return _build_result(DEFAULT_ANALYSIS_RESULT, is_mock=False, error="Timeout") - except (KeyError, IndexError, json.JSONDecodeError) as e: - logger.error(f"Gemini 응답 파싱 실패: {str(e)}") + except (KeyError, IndexError, json.JSONDecodeError) as exception: + logger.error( + "Gemini 응답 파싱 실패. error_type=%s", + type(exception).__name__, + ) return _build_result(DEFAULT_ANALYSIS_RESULT, is_mock=False, error="Parse Error") - except Exception as e: - logger.error(f"Gemini 연동 중 비정상 에러 발생: {str(e)}") + except Exception as exception: + logger.error( + "Gemini 연동 중 비정상 오류 발생. error_type=%s", + type(exception).__name__, + ) return _build_result(DEFAULT_ANALYSIS_RESULT, is_mock=False, error="Unknown Error") diff --git a/app/analysis/text/naive_bayes_analyzer.py b/app/analysis/text/naive_bayes_analyzer.py index 746a72b..93305f2 100644 --- a/app/analysis/text/naive_bayes_analyzer.py +++ b/app/analysis/text/naive_bayes_analyzer.py @@ -1,18 +1,13 @@ -import os import re import logging -from pathlib import Path from app.analysis.risk_policy import determine_text_risk_grade +from app.core.config import settings logger = logging.getLogger(__name__) -# 프로젝트 루트 기준 사전 학습된 아티팩트 위치 (data_science/SMSModel/train_sms.py 산출물) -_BASE_DIR = Path(__file__).resolve().parents[3] -_DEFAULT_MODEL_DIR = _BASE_DIR / "data_science" / "SMSModel" - -MODEL_PATH = Path(os.getenv("NAIVE_BAYES_MODEL_PATH", str(_DEFAULT_MODEL_DIR / "phishing_model_artifact.pkl"))) -VECTORIZER_PATH = Path(os.getenv("NAIVE_BAYES_VECTORIZER_PATH", str(_DEFAULT_MODEL_DIR / "phishing_vectorizer.pkl"))) +MODEL_PATH = settings.NAIVE_BAYES_MODEL_PATH +VECTORIZER_PATH = settings.NAIVE_BAYES_VECTORIZER_PATH # --- 전처리 정규식 : data_science/SMSModel/train_sms.py의 정규화/피처 추출 로직과 반드시 동일하게 유지 --- # (학습 시 벡터라이저가 본 입력 분포와 서빙 시 입력 분포가 어긋나면 모델이 무의미해짐) @@ -92,9 +87,12 @@ def _load_artifacts() -> None: _classes = artifact["classes"] _vectorizer = joblib.load(VECTORIZER_PATH) logger.info(f"[NaiveBayes] 모델 로드 완료 (threshold={_threshold})") - except Exception as e: - _load_error = str(e) - logger.error(f"[NaiveBayes] 모델 로드 실패: {_load_error}") + except Exception as exception: + _load_error = type(exception).__name__ + logger.error( + "[NaiveBayes] 모델 로드 실패. error_type=%s", + _load_error, + ) def is_model_loaded() -> bool: @@ -139,8 +137,11 @@ async def analyze_text_with_naive_bayes(text: str) -> dict: "error_message": None } } - except Exception as e: - logger.error(f"[NaiveBayes] 추론 중 비정상 에러 발생: {str(e)}") + except Exception as exception: + logger.error( + "[NaiveBayes] 추론 중 비정상 오류 발생. error_type=%s", + type(exception).__name__, + ) return { "engine": "naive_bayes", "is_available": False, diff --git a/app/analysis/url/analyzer.py b/app/analysis/url/analyzer.py index 02ddddc..339c029 100644 --- a/app/analysis/url/analyzer.py +++ b/app/analysis/url/analyzer.py @@ -1,8 +1,8 @@ import logging -import os from typing import ClassVar from app.analysis.ports import UrlSecurityProvider +from app.core.config import settings from app.infrastructure.google_safe_browsing.client import ( GoogleSafeBrowsingClient, ) @@ -12,10 +12,7 @@ logger = logging.getLogger(__name__) -MOCK_ENABLED = ( - os.getenv("MOCK_SECURITY_API", "False").lower() - in ("true", "1", "t") -) +MOCK_ENABLED = settings.MOCK_SECURITY_API # Google Safe Browsing(1차)과 VirusTotal(2차 백업)을 제어하는 하이브리드 URL 분석 코어 엔진 class HybridUrlAnalyzer: @@ -50,7 +47,7 @@ def _is_unavailable(result: dict) -> bool: async def scan_url(self, traced_url: str) -> dict: # 쉘 환경변수에 따른 MOCK 모드 분기 로직 정상화 if MOCK_ENABLED: - logger.info(f"[MOCK MODE] 하이브리드 URL 스캔 -> Target: {traced_url}") + logger.info("[MOCK MODE] 하이브리드 URL 스캔 시작") return { "is_malicious": True, "url_risk_score": 0.85, @@ -237,9 +234,10 @@ async def _scan_gsb( traced_url ) - except Exception: - logger.exception( - "[Hybrid URL] GSB 호출 중 예외 발생" + except Exception as exception: + logger.error( + "[Hybrid URL] GSB 호출 중 예외 발생. error_type=%s", + type(exception).__name__, ) return { "is_malicious": False, @@ -258,9 +256,10 @@ async def _scan_virustotal( traced_url ) - except Exception: - logger.exception( - "[Hybrid URL] VirusTotal 호출 중 예외 발생" + except Exception as exception: + logger.error( + "[Hybrid URL] VirusTotal 호출 중 예외 발생. error_type=%s", + type(exception).__name__, ) return { "is_malicious": False, diff --git a/app/analysis/url/tracker.py b/app/analysis/url/tracker.py index 6671e30..f58273c 100644 --- a/app/analysis/url/tracker.py +++ b/app/analysis/url/tracker.py @@ -59,7 +59,9 @@ async def _is_public_host( loop.getaddrinfo(hostname, None), timeout=dns_timeout ) except (socket.gaierror, OSError, asyncio.TimeoutError): - logger.warning(f"[SSRF Guard] DNS 조회 실패/타임아웃으로 안전하게 차단: {hostname}") + logger.warning( + "[SSRF Guard] DNS 조회 실패 또는 타임아웃으로 요청 차단" + ) return None pinned_ip = None @@ -163,7 +165,9 @@ async def trace_url( hostname = urlparse(current_url).hostname pinned_ip = await _is_public_host(hostname, dns_timeout=timeout) if not pinned_ip: - logger.warning(f"[SSRF Guard] 내부망/사설 주소로 판단되어 요청 차단: {current_url}") + logger.warning( + "[SSRF Guard] 내부망 또는 사설 주소로 판단되어 요청 차단" + ) break # 검증에 쓴 IP를 그대로 연결에 고정(pin)한다. 홉마다 호스트가 바뀔 수 있으므로 @@ -212,18 +216,30 @@ async def trace_url( location = urljoin(current_url, location) current_url = location - logger.info(f"Redirect {attempt + 1}: -> {current_url}") + logger.info( + "URL redirect followed. redirect_count=%d", + attempt + 1, + ) else: break except httpx.TimeoutException: - logger.warning(f"URL 추적 타임아웃 발생 ({timeout}초 초과): {current_url}") + logger.warning( + "URL 추적 타임아웃 발생. timeout_seconds=%s", + timeout, + ) break - except httpx.HTTPStatusError as e: - logger.error(f"HTTP 에러 발생 ({e.response.status_code}): {current_url}") + except httpx.HTTPStatusError as exception: + logger.error( + "URL 추적 HTTP 오류. status_code=%s", + exception.response.status_code, + ) break - except Exception as e: - logger.error(f"비정상 URL 추적 실패 ({str(e)}): {current_url}") + except Exception as exception: + logger.error( + "URL 추적 실패. error_type=%s", + type(exception).__name__, + ) break else: logger.warning(f"최대 리다이렉트 횟수({max_redirects}회)를 초과했습니다. 루프 위험 감지.") diff --git a/app/chat/router.py b/app/chat/router.py index b41caef..709cd72 100644 --- a/app/chat/router.py +++ b/app/chat/router.py @@ -25,13 +25,19 @@ async def chat( chat_service: ChatService = Depends(get_chat_service), ) -> ChatResponse: # 대화 내용/분석 컨텍스트는 로그에 원문으로 남기지 않음 - logger.info(f"[Router] 챗봇 요청 진입 - 메시지 수: {len(payload.messages)}") + logger.info( + "[Router] 챗봇 요청 진입. message_count=%d", + len(payload.messages), + ) try: return await chat_service.get_response(payload) - except ChatServiceError as e: - logger.error(f"[Router] 챗봇 응답 생성 실패: {str(e)}") + except ChatServiceError as exception: + logger.error( + "[Router] 챗봇 응답 생성 실패. error_type=%s", + type(exception).__name__, + ) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, detail="챗봇 응답 생성 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", - ) + ) from exception diff --git a/app/chat/service.py b/app/chat/service.py index d4f0067..04945d5 100644 --- a/app/chat/service.py +++ b/app/chat/service.py @@ -1,22 +1,19 @@ -import os import logging import httpx -from dotenv import load_dotenv from app.chat.prompts import build_system_prompt from app.chat.schemas import ChatMessage, ChatRequest, ChatResponse, ChatRole +from app.core.config import settings from app.infrastructure.gemini.client import GeminiClient -load_dotenv() - logger = logging.getLogger(__name__) -GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") -GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-flash-latest") +GEMINI_API_KEY = settings.GEMINI_API_KEY +GEMINI_MODEL = settings.GEMINI_MODEL API_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent" -MOCK_ENABLED = os.getenv("MOCK_SECURITY_API", "False").lower() in ("true", "1", "t") +MOCK_ENABLED = settings.MOCK_SECURITY_API MOCK_RESPONSE_MESSAGE = ( "[시연용 응답] 해당 문자는 위험도가 높게 분석되었습니다. " diff --git a/app/core/config.py b/app/core/config.py index 1ccd349..5c55ea5 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,13 +1,28 @@ -from pydantic import Field +from pathlib import Path +from typing import Literal + +from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict + class Settings(BaseSettings): + ENV: Literal["local", "test", "prod"] = "local" PROJECT_NAME: str = "SafeFam-AI" VERSION: str = "1.0.0" API_V1_STR: str = "/api/v1" - + + GEMINI_API_KEY: str | None = None + GEMINI_MODEL: str = "gemini-flash-latest" VIRUSTOTAL_API_KEY: str | None = None GOOGLE_SAFE_BROWSING_API_KEY: str | None = None + MOCK_SECURITY_API: bool = False + + NAIVE_BAYES_MODEL_PATH: Path = Path( + "data_science/SMSModel/phishing_model_artifact.pkl" + ) + NAIVE_BAYES_VECTORIZER_PATH: Path = Path( + "data_science/SMSModel/phishing_vectorizer.pkl" + ) GEMINI_TIMEOUT_SECONDS: float = Field( default=10.0, @@ -75,10 +90,50 @@ class Settings(BaseSettings): ge=0, ) + @model_validator(mode="after") + def validate_production_settings(self): + if self.ENV != "prod": + return self + + required_values = { + "GEMINI_API_KEY": self.GEMINI_API_KEY, + "VIRUSTOTAL_API_KEY": self.VIRUSTOTAL_API_KEY, + "GOOGLE_SAFE_BROWSING_API_KEY": ( + self.GOOGLE_SAFE_BROWSING_API_KEY + ), + "RABBITMQ_URL": self.RABBITMQ_URL, + } + missing = [ + name + for name, value in required_values.items() + if value is None or not str(value).strip() + ] + + local_rabbitmq_url = ( + "amqp://safefam:safefam-local@localhost:5672/" + ) + if self.RABBITMQ_URL == local_rabbitmq_url: + missing.append("RABBITMQ_URL") + + if missing: + raise ValueError( + "Missing required production settings: " + + ", ".join(sorted(set(missing))) + ) + + if self.MOCK_SECURITY_API: + raise ValueError( + "MOCK_SECURITY_API must be false in production" + ) + + return self + model_config = SettingsConfigDict( - env_file=".env", + env_file=".env", env_file_encoding="utf-8", - extra="ignore" + extra="ignore", + hide_input_in_errors=True, ) + settings = Settings() diff --git a/app/infrastructure/google_safe_browsing/client.py b/app/infrastructure/google_safe_browsing/client.py index 02bda8c..1ad45aa 100644 --- a/app/infrastructure/google_safe_browsing/client.py +++ b/app/infrastructure/google_safe_browsing/client.py @@ -93,8 +93,7 @@ async def scan_url(self, url: str) -> dict: # 매칭되는 위험 요소가 있는 경우 악성 URL로 처리 if matches: logger.warning( - "[Google Safe Browsing] 악성 URL 감지됨: %s", - url, + "[Google Safe Browsing] 악성 URL 감지됨" ) return { "is_malicious": True, @@ -105,8 +104,7 @@ async def scan_url(self, url: str) -> dict: } logger.info( - "[Google Safe Browsing] 안전한 URL: %s", - url, + "[Google Safe Browsing] URL 분석 완료" ) return self._safe_result() @@ -143,10 +141,11 @@ async def scan_url(self, url: str) -> dict: "NETWORK_ERROR" ) - except Exception: - logger.exception( + except Exception as exception: + logger.error( "[Google Safe Browsing] " - "연동 중 비정상 오류 발생" + "연동 중 비정상 오류 발생. error_type=%s", + type(exception).__name__, ) return self._unavailable_result( "UNEXPECTED_ERROR" diff --git a/app/infrastructure/mock_provider.py b/app/infrastructure/mock_provider.py index c55756f..9983358 100644 --- a/app/infrastructure/mock_provider.py +++ b/app/infrastructure/mock_provider.py @@ -31,7 +31,7 @@ async def scan_url(self, url: str) -> dict: [상용 인터페이스 구현] 가상 DB를 매칭하여 상용 엔진과 동일한 규격의 딕셔너리를 반환합니다. """ - logger.info(f"🚨 [MOCK SECURITY ENGINE] Sandbox API 우회 매칭 -> {url}") + logger.info("[MOCK SECURITY ENGINE] Sandbox API 우회 매칭") # URL이 DB에 없으면 안전한 상태인 default_safe 적용 data = self.mock_db.get(url, self.default_safe) diff --git a/app/infrastructure/virustotal/client.py b/app/infrastructure/virustotal/client.py index 5a47ce2..e06471c 100644 --- a/app/infrastructure/virustotal/client.py +++ b/app/infrastructure/virustotal/client.py @@ -165,9 +165,8 @@ async def scan_url(self, url: str) -> dict: status_code = exc.response.status_code logger.error( - "[VirusTotal] API 에러 (%s): %s", + "[VirusTotal] API 에러. status_code=%s", status_code, - exc, ) if status_code == 429: @@ -187,16 +186,17 @@ async def scan_url(self, url: str) -> dict: except httpx.RequestError as exc: logger.error( - "[VirusTotal] 네트워크 오류: %s", - exc, + "[VirusTotal] 네트워크 오류. error_type=%s", + type(exc).__name__, ) return self._unavailable_result( "NETWORK_ERROR" ) - except Exception: - logger.exception( - "[VirusTotal] 연동 중 비정상 오류 발생" + except Exception as exception: + logger.error( + "[VirusTotal] 연동 중 비정상 오류 발생. error_type=%s", + type(exception).__name__, ) return self._unavailable_result( "UNEXPECTED_ERROR" @@ -211,8 +211,7 @@ async def _request_new_scan( """기존 보고서가 없는 URL에 대해 VT에 신규 스캔 분석 요청""" logger.info( "[VirusTotal] 기존 보고서 없음. " - "신규 스캔 요청 시작: %s", - url, + "신규 스캔 요청 시작", ) scan_url = f"{self.base_url}/urls" @@ -238,7 +237,6 @@ async def _request_new_scan( scan_response.raise_for_status() logger.info( - "[VirusTotal] 신규 스캔 요청 완료: %s", - url, + "[VirusTotal] 신규 스캔 요청 완료", ) - return self._scanning_result() \ No newline at end of file + return self._scanning_result() diff --git a/app/main.py b/app/main.py index b2fd717..e26f8a7 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,6 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -28,6 +29,24 @@ ) +def validate_model_files() -> None: + """운영 시작 전에 필수 모델 파일이 존재하고 읽을 수 있는지 검증한다.""" + required_files: tuple[Path, ...] = ( + settings.NAIVE_BAYES_MODEL_PATH, + settings.NAIVE_BAYES_VECTORIZER_PATH, + ) + missing = [ + str(path) + for path in required_files + if not path.is_file() + ] + if missing: + raise RuntimeError( + "Required AI model files are missing: " + + ", ".join(missing) + ) + + def create_lifespan( rabbitmq_consumer_enabled: bool, ): @@ -36,6 +55,9 @@ def create_lifespan( async def lifespan( application: FastAPI, ) -> AsyncIterator[None]: + if settings.ENV == "prod": + validate_model_files() + if not rabbitmq_consumer_enabled: yield return diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..9b84a9a --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,20 @@ +services: + fastapi-ai: + image: safefam/safefam-ai:${FASTAPI_IMAGE_TAG:?FASTAPI_IMAGE_TAG is required} + container_name: safefam-ai-server + restart: unless-stopped + env_file: + - ${ENV_FILE:-.env.runtime} + command: + - uvicorn + - app.main:app + - --host + - 0.0.0.0 + - --port + - "8000" + networks: + - safefam-network + +networks: + safefam-network: + external: true diff --git a/requirements.txt b/requirements.txt index eb3974d..c512a53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,8 +4,8 @@ pydantic>=2.6.0 pydantic-settings>=2.2.0 httpx>=0.27.0 python-dotenv>=1.0.0 -scikit-learn==1.7.2 +scikit-learn==1.8.0 scipy>=1.16.0 numpy>=2.0.0,<2.5.0 joblib>=1.5.0 -aio-pika>=9.4.0,<10.0.0 \ No newline at end of file +aio-pika>=9.4.0,<10.0.0 diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..946df15 --- /dev/null +++ b/tests/core/__init__.py @@ -0,0 +1 @@ +"""Core configuration tests.""" diff --git a/tests/core/test_config.py b/tests/core/test_config.py new file mode 100644 index 0000000..d392ef5 --- /dev/null +++ b/tests/core/test_config.py @@ -0,0 +1,77 @@ +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from app.core.config import Settings +from app.main import validate_model_files + + +def _production_values() -> dict[str, object]: + return { + "ENV": "prod", + "GEMINI_API_KEY": "test-gemini-key", + "VIRUSTOTAL_API_KEY": "test-virustotal-key", + "GOOGLE_SAFE_BROWSING_API_KEY": "test-gsb-key", + "RABBITMQ_URL": "amqp://user:password@rabbitmq:5672/", + "MOCK_SECURITY_API": False, + } + + +def test_production_settings_require_secrets(): + with pytest.raises(ValidationError) as exception_info: + Settings(ENV="prod", _env_file=None) + + error_message = str(exception_info.value) + assert "Missing required production settings" in error_message + assert "input_value" not in error_message + + +def test_production_settings_reject_mock_mode(): + values = _production_values() + values["MOCK_SECURITY_API"] = True + + with pytest.raises(ValidationError, match="must be false"): + Settings(**values, _env_file=None) + + +def test_production_settings_accept_complete_configuration(): + configured = Settings(**_production_values(), _env_file=None) + + assert configured.ENV == "prod" + assert configured.MOCK_SECURITY_API is False + + +def test_model_validation_rejects_missing_files(monkeypatch, tmp_path): + missing_model = tmp_path / "missing-model.pkl" + missing_vectorizer = tmp_path / "missing-vectorizer.pkl" + + monkeypatch.setattr( + "app.main.settings.NAIVE_BAYES_MODEL_PATH", + missing_model, + ) + monkeypatch.setattr( + "app.main.settings.NAIVE_BAYES_VECTORIZER_PATH", + missing_vectorizer, + ) + + with pytest.raises(RuntimeError, match="Required AI model files"): + validate_model_files() + + +def test_model_validation_accepts_readable_files(monkeypatch, tmp_path): + model_path = Path(tmp_path / "model.pkl") + vectorizer_path = Path(tmp_path / "vectorizer.pkl") + model_path.write_bytes(b"model") + vectorizer_path.write_bytes(b"vectorizer") + + monkeypatch.setattr( + "app.main.settings.NAIVE_BAYES_MODEL_PATH", + model_path, + ) + monkeypatch.setattr( + "app.main.settings.NAIVE_BAYES_VECTORIZER_PATH", + vectorizer_path, + ) + + validate_model_files()