diff --git a/Dockerfile b/Dockerfile index e3d740d..6eb72ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,6 @@ 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 @@ -15,8 +13,6 @@ RUN python -c "from kiwipiepy import Kiwi; assert Kiwi().tokenize('installation COPY app app COPY data_science/SMSModel/modeling data_science/SMSModel/modeling COPY data_science/SMSModel/tokenization data_science/SMSModel/tokenization -COPY data_science/SMSModel/artifacts/phishing_model_artifact.pkl models/phishing_model_artifact.pkl -COPY data_science/SMSModel/artifacts/phishing_vectorizer.pkl models/phishing_vectorizer.pkl COPY data_science/SMSModel/artifacts/stacking data_science/SMSModel/artifacts/stacking RUN useradd --create-home --shell /usr/sbin/nologin safefam \ diff --git a/app/analysis/institution/analyzer.py b/app/analysis/institution/analyzer.py index 13d5c38..c8dbc1f 100644 --- a/app/analysis/institution/analyzer.py +++ b/app/analysis/institution/analyzer.py @@ -37,10 +37,14 @@ def _is_official_domain(domain: str, official_domains: tuple[str, ...]) -> bool: def _find_mentioned_institution(text: str) -> OfficialInstitution | None: tags = _RE_BRACKET_TAG.findall(text) - for institution in OFFICIAL_INSTITUTIONS: - if any(alias in tag for tag in tags for alias in institution.aliases): - return institution - return None + matches = ( + (alias, institution) + for institution in OFFICIAL_INSTITUTIONS + for alias in institution.aliases + if any(alias in tag for tag in tags) + ) + longest_match = max(matches, key=lambda match: len(match[0]), default=None) + return longest_match[1] if longest_match else None # 문자에 언급된 기관명과 실제 링크된 URL의 도메인이 그 기관의 공식 도메인과 일치하는지 대조 diff --git a/app/analysis/institution/registry.py b/app/analysis/institution/registry.py index a154ecf..7928742 100644 --- a/app/analysis/institution/registry.py +++ b/app/analysis/institution/registry.py @@ -40,6 +40,16 @@ class OfficialInstitution: ("epost.go.kr", "koreapost.go.kr", "epostbank.go.kr"), ("1588-1300", "1599-1900", "1599-0100"), ), + OfficialInstitution( + "SC제일은행", ("SC제일은행",), ("sc.co.kr", "standardchartered.co.kr"), ("1588-1599",) + ), + OfficialInstitution( + "한국씨티은행", ("한국씨티은행", "씨티은행"), ("citibank.co.kr",), ("1588-7000",) + ), + OfficialInstitution("수협은행", ("수협은행",), ("suhyup-bank.com",), ("1588-1515", "1644-1515")), + OfficialInstitution( + "산업은행", ("산업은행", "KDB산업은행"), ("kdb.co.kr",), ("1588-1500", "1668-1500") + ), # 카드사 OfficialInstitution("신한카드", ("신한카드",), ("shinhancard.com",), ("1544-7000",)), @@ -48,6 +58,31 @@ class OfficialInstitution: OfficialInstitution("KB국민카드", ("KB국민카드", "국민카드"), ("kbcard.com",), ("1588-1688",)), OfficialInstitution("롯데카드", ("롯데카드",), ("lottecard.co.kr",), ("1588-8100",)), OfficialInstitution("우리카드", ("우리카드",), ("wooricard.com",), ("1588-9955", "1599-9955")), + OfficialInstitution("하나카드", ("하나카드",), ("hanacard.co.kr",), ("1800-1111",)), + OfficialInstitution("BC카드", ("BC카드", "비씨카드"), ("bccard.com",), ("1588-4000",)), + OfficialInstitution( + "NH농협카드", ("NH농협카드", "농협카드"), ("nonghyup.com",), ("1644-4000",) + ), + OfficialInstitution("씨티카드", ("씨티카드",), ("citibank.co.kr",), ("1566-1000",)), + + # 증권사 + OfficialInstitution( + "미래에셋증권", ("미래에셋증권",), ("securities.miraeasset.com",), ("1588-6800",) + ), + OfficialInstitution("삼성증권", ("삼성증권",), ("samsungpop.com",), ("1588-2323",)), + OfficialInstitution( + "한국투자증권", + ("한국투자증권",), + ("truefriend.com", "koreainvestment.com"), + ("1544-5000", "1588-0012"), + ), + OfficialInstitution("NH투자증권", ("NH투자증권",), ("nhqv.com", "nhsec.com")), + OfficialInstitution("키움증권", ("키움증권",), ("kiwoom.com",), ("1544-9000",)), + + # 보험사 + OfficialInstitution("삼성생명", ("삼성생명",), ("samsunglife.com",)), + OfficialInstitution("교보생명", ("교보생명",), ("kyobo.com",), ("1588-1001",)), + OfficialInstitution("한화생명", ("한화생명",), ("hanwhalife.com",), ("1588-6363",)), # 공공/사법기관 OfficialInstitution("금융감독원", ("금융감독원",), ("fss.or.kr",), ("1332",)), @@ -55,7 +90,7 @@ class OfficialInstitution: OfficialInstitution("경찰청", ("경찰청",), ("police.go.kr",), ("182",)), OfficialInstitution("검찰청", ("검찰청",), ("spo.go.kr",), ("1301",)), - # 홈택스(hometax.go.kr)는 국세청이 직접 운영하는 전자세정 서비스 도메인이라 함께 등록 + # 홈택스(hometax.go.kr)는 국세청이 직접 운영하는 전자세정 서비스 도메인이라 함께 등록 OfficialInstitution("국세청", ("국세청",), ("nts.go.kr", "hometax.go.kr"), ("126",)), OfficialInstitution("관세청", ("관세청",), ("customs.go.kr",), ("125",)), OfficialInstitution( @@ -64,4 +99,5 @@ class OfficialInstitution: ("nhis.or.kr",), ("1577-1000",), ), + OfficialInstitution("법원", ("법원", "대법원"), ("scourt.go.kr",), ("02-3480-1100",)), ) diff --git a/app/main.py b/app/main.py index 34ee29b..c4b8a57 100644 --- a/app/main.py +++ b/app/main.py @@ -1,13 +1,12 @@ from collections.abc import AsyncIterator from contextlib import asynccontextmanager -from pathlib import Path from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.analysis import router as analyze from app.analysis.service import SmishingAnalysisService -from app.analysis.text import naive_bayes_analyzer +from app.analysis.text.stacking_analyzer import is_stacking_model_loaded from app.chat import router as chat from app.core.config import settings from app.infrastructure.rabbitmq.connection import ( @@ -31,14 +30,9 @@ def validate_model_files() -> None: - """운영 시작 전에 필수 모델 파일이 존재하고 읽을 수 있는지 검증한다.""" - required_files: tuple[Path, ...] = naive_bayes_analyzer.resolve_artifact_paths( - 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)) + """운영 시작 전에 실제 서비스가 사용하는 stacking 모델을 로드할 수 있는지 검증한다.""" + if not is_stacking_model_loaded(): + raise RuntimeError("Stacking model artifact is missing or invalid") def create_lifespan( diff --git a/tests/analysis/institution/test_analyzer.py b/tests/analysis/institution/test_analyzer.py index be79764..7fe461d 100644 --- a/tests/analysis/institution/test_analyzer.py +++ b/tests/analysis/institution/test_analyzer.py @@ -1,3 +1,5 @@ +import pytest + from app.analysis.institution.analyzer import analyze_institution_match @@ -67,6 +69,14 @@ def test_official_subdomain_is_recognized(): assert result["mismatch"] is False +@pytest.mark.parametrize("sender_name", ["NH농협카드", "농협카드"]) +def test_longest_alias_match_selects_nh_card_over_nh_bank(sender_name): + result = analyze_institution_match(f"[{sender_name}] 이용 안내", traced_url=None) + + assert result["institution"] == "NH농협카드" + assert result["official_domains"] == ["nonghyup.com"] + + def test_institution_name_outside_bracket_tag_is_not_treated_as_sender(): """ "[롯데택배] ... 보내신 분: 농협하나로마트" 처럼 발신 주체가 아니라 상호명 등으로 diff --git a/tests/analysis/institution/test_registry.py b/tests/analysis/institution/test_registry.py new file mode 100644 index 0000000..87ee129 --- /dev/null +++ b/tests/analysis/institution/test_registry.py @@ -0,0 +1,20 @@ +from app.analysis.institution.registry import OFFICIAL_INSTITUTIONS +from app.analysis.rules.analyzer import FINANCIAL_INSTITUTIONS + + +def test_every_scored_institution_has_an_official_domain_entry(): + """FINANCIAL_INSTITUTIONS의 언급 가점(+15)만 받고 OFFICIAL_INSTITUTIONS의 도메인 + 위변조 검사(+50, 단독으로 HIGH급)는 못 받는 기관이 생기지 않도록 막는다. 두 목록이 + 벌어지면 그 기관을 사칭한 문자는 가장 강한 시그널 없이 조용히 약하게만 잡힌다.""" + missing = [ + name + for name in FINANCIAL_INSTITUTIONS + if not any( + name in institution.aliases and institution.official_domains + for institution in OFFICIAL_INSTITUTIONS + ) + ] + + assert not missing, ( + f"OFFICIAL_INSTITUTIONS에 도메인 등록이 안 된 기관: {missing}" + ) diff --git a/tests/core/test_config.py b/tests/core/test_config.py index df62b95..0ba3141 100644 --- a/tests/core/test_config.py +++ b/tests/core/test_config.py @@ -1,5 +1,3 @@ -from pathlib import Path - import pytest from pydantic import ValidationError @@ -82,36 +80,14 @@ def test_settings_reject_invalid_stacking_probability_bounds( ) -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, - ) +def test_model_validation_rejects_when_stacking_model_unavailable(monkeypatch): + monkeypatch.setattr("app.main.is_stacking_model_loaded", lambda: False) - with pytest.raises(RuntimeError, match="Required AI model files"): + with pytest.raises(RuntimeError, match="Stacking model"): 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, - ) +def test_model_validation_accepts_when_stacking_model_available(monkeypatch): + monkeypatch.setattr("app.main.is_stacking_model_loaded", lambda: True) validate_model_files()