From d46bba4bb25f9758c6388620e0949e74976f483c Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Tue, 31 Mar 2026 20:29:24 +0900 Subject: [PATCH 01/77] =?UTF-8?q?sse=20=ED=86=B5=EC=8B=A0=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=86=A0=ED=83=80=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 16 +++++++++++++++- services/sse.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 services/sse.py diff --git a/main.py b/main.py index 315b45a..3c49f13 100644 --- a/main.py +++ b/main.py @@ -3,6 +3,9 @@ from pydantic import BaseModel from datetime import datetime +from fastapi.responses import StreamingResponse +from services.sse import event_generator, send_step + from analyzers.req_api import check_url_safe_browsing from analyzers.req_db import check_url_exists @@ -19,6 +22,12 @@ def root(): @app.post("/analyze") def analyze(data: AnalyzeRequest): + "SSE작동 테스트용 호출" + send_step("start") + send_step("checking_input") + send_step("building_response") + send_step("done") + return{ "qr_url": data.qr_url, "respond_test": "OK", @@ -36,4 +45,9 @@ def test_safe_browsing(url: str): # DB 조회 테스트 @app.get("/test/db") def test_db(url: str): - return check_url_exists(url) \ No newline at end of file + return check_url_exists(url) + +# SSE 엔드포인트 +@app.get("/stream") +def stream(): + return StreamingResponse(event_generator(), media_type="text/event-stream") \ No newline at end of file diff --git a/services/sse.py b/services/sse.py new file mode 100644 index 0000000..687563b --- /dev/null +++ b/services/sse.py @@ -0,0 +1,30 @@ +#기능서버->프론트 서버 이벤트 모듈 +#진행 단계 알림이 +""" +현재 시스템상 모든 사용자가 같은 큐를 공유함 +=여러 사용자 동시 진행 불가능 +추후 request_id별 분리 +""" +from queue import Queue +import time + +# 전역 큐 (이벤트 통로) +event_queue = Queue() + + +# 외부에서 호출할 함수 (핵심) +def send_step(step: str): + event_queue.put(step) + + +# SSE용 generator +def event_generator(): + # 처음 연결되면 인사 한 번 + yield "data: hello\n\n" + + while True: + if not event_queue.empty(): + step = event_queue.get() + yield f"data: {step}\n\n" + + time.sleep(0.1) \ No newline at end of file From a2128ed71fa6b911f9680bf3c45fa98360877d7d Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sun, 5 Apr 2026 00:52:18 +0900 Subject: [PATCH 02/77] =?UTF-8?q?**feat**:=20URL=20=EC=A0=95=EA=B7=9C?= =?UTF-8?q?=ED=99=94+HTTPS=EC=97=AC=EB=B6=80=20=EA=B2=80=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/url_https_checker.py | 3 - services/url_utils.py | 168 ++++++++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 4 deletions(-) delete mode 100644 analyzers/url_https_checker.py diff --git a/analyzers/url_https_checker.py b/analyzers/url_https_checker.py deleted file mode 100644 index accda83..0000000 --- a/analyzers/url_https_checker.py +++ /dev/null @@ -1,3 +0,0 @@ -# 프로토콜-스킴 파트 분석. -# https 여부 확인이 주요 기능 -# 이건 그래도 파이프라인에 집어넣을거 생각하면 클래스 분리되어 있는 편이 나을듯 diff --git a/services/url_utils.py b/services/url_utils.py index c31427a..1a91c62 100644 --- a/services/url_utils.py +++ b/services/url_utils.py @@ -1 +1,167 @@ -# URL을 분해하고, 정규화하고, 필요하다면 관련 다른 기능들도 제공합니다. \ No newline at end of file +# URL을 분해하고, 정규화하고, 필요하다면 관련 다른 기능들도 제공합니다. +from urllib.parse import urlsplit, urlunsplit + + +#입력 URL의 앞뒤 공백/탭/개행 제거 +#pipeline 조립 시 수신 url에 대해 반드시 첫 번째로 사용 +def sanitize_input_url(raw_url: str) -> str: + + cleaned_url = "" # 공백 제거 후 사용할 URL 문자열 + + if raw_url is None: + raise ValueError("URL 입력값이 None입니다.") + + cleaned_url = raw_url.strip() + if not cleaned_url: + raise ValueError("URL 입력값이 비어 있습니다.") + + return cleaned_url + + +#URL을 구성 요소별로 분해 +def parse_url_components(url: str) -> dict: + + split_result = urlsplit(url) # urlsplit() 결과 객체 + + scheme = split_result.scheme # URL scheme + netloc = split_result.netloc # 도메인+포트 + hostname = split_result.hostname # 순수 도메인 + port = split_result.port # 포트 번호 + path = split_result.path # 경로 + query = split_result.query # 쿼리 문자열 + fragment = split_result.fragment # fragment + + return { + "scheme": scheme, + "netloc": netloc, + "hostname": hostname, + "port": port, + "path": path, + "query": query, + "fragment": fragment + } #parsed_result + + +#URL에 scheme(http,https 등)이 존재하는지 확인 +#없을 시 https 부착 **검사 진행 위한 구조 정상화 목적** +#소문자화 scheme, scheme 존재 여부, https 여부를 반환합니다. +def normalize_scheme(parsed_result:dict): + + scheme = parsed_result["scheme"] # scheme + scheme = scheme.lower() # 소문자로 저장 + scheme_exists = bool(scheme) # 보정 전 scheme 존재 여부 + is_https = (scheme == "https") # https 여부 (보정 전 기준) + + #scheme 미존재시 검사용으로 https 부착 + if scheme_exists is False: + scheme = "https" + + return { + "normalized_scheme": scheme, + "had_scheme_originally": scheme_exists, # 원본 scheme 존재 여부 + "is_https": is_https # 원본 https 여부 + } + + +#hostname 소문자화, 기본 포트 여부 판단 +def normalize_url_components(parsed_result: dict, normalized_scheme: str): + + hostname = parsed_result["hostname"] # hostname + port = parsed_result["port"] # port + is_default_port = False # 기본 포트 여부(http:80/https:443) + + if hostname != None: + hostname = hostname.lower() + else: + hostname = "" + + # 기본 포트 여부 확인(http/https의 경우만) + if normalized_scheme == "http" and port == 80: + is_default_port = True + port = None + elif normalized_scheme == "https" and port == 443: + is_default_port = True + port = None + + return { + "normalized_hostname": hostname, + "normalized_port": port, # 기본 포트 제거 반영 + "is_default_port": is_default_port, # 기본 포트 여부 + } #normalized_comp + + +#URL 재구성 +def rebuild_normalized_url(parsed_result: dict, normalized_scheme: str, normalized_comp: dict): + + # hostname+port + if normalized_comp["normalized_port"] is None: + netloc = normalized_comp["normalized_hostname"] + + else: + netloc = f"{normalized_comp['normalized_hostname']}:{normalized_comp['normalized_port']}" + + # normalized url (output) + integrated_url = urlunsplit(( + normalized_scheme, + netloc, + parsed_result["path"], + parsed_result["query"], + parsed_result["fragment"])) + + return { + "netloc": netloc, + "normalized_url": integrated_url + } + + +# 정규화된 URL이 분석 가능한 형태인지 검증합니다.. .d별로 의미 없어보이는데. +# 굳이해야되나? 안전상 확실하게 하려면 하는게 좋기는 하지만... 위에서 검사 한번씩 다 거쳤을텐데 +def validate_normalized_url(parsed_result: dict, scheme_result: dict, normalized_comp: dict): + + normalized_scheme = scheme_result["normalized_scheme"] # 정규화된 scheme + normalized_hostname = normalized_comp["normalized_hostname"] # 정규화된 hostname + + is_valid = True # 전체 유효성 여부 + error_reason = "" # 유효하지 않은 경우 사유 + + # hostname 존재 여부 확인 + if not normalized_hostname: + is_valid = False + error_reason = "hostname이 존재하지 않습니다." + + # scheme 허용 여부 확인 + elif normalized_scheme not in ("http", "https"): + is_valid = False + error_reason = "허용되지 않은 scheme입니다." + + return { + "is_valid": is_valid, + "error_reason": error_reason + } + +# 결과 통합 함수. URL 분석 결과를 불러올 때 사용합니다. +# 타 검사에서 활용할 데이터는 해당 dict에서 추출 +def build_url_utils_result( + raw_url: str, + cleaned_url: str, + parsed_result: dict, + scheme_result: dict, + normalized_comp: dict, + rebuilt_result: dict, + validation_result: dict +): + + return { + "raw_url": raw_url, # 원본 URL + "cleaned_url": cleaned_url, # 공백 제거 후 URL + + "parsed": parsed_result, # 분해 결과 + "scheme": scheme_result, # scheme 정규화 결과 (HTTPS 여부는 해당 부분에 포함) + "components": normalized_comp, # hostname/port 정규화 결과 + "rebuilt": rebuilt_result, # 재구성 결과 + "validation": validation_result, # 유효성 검증 결과 + + "normalized_url": rebuilt_result["normalized_url"], # 최종 정규화 URL + "is_valid": validation_result["is_valid"], # 최종 유효 여부 + "error_reason": validation_result["error_reason"] # 오류 사유 + } \ No newline at end of file From 69abe7b27f685ee9dd9ba6a031edcb7db42d4735 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sun, 5 Apr 2026 20:14:44 +0900 Subject: [PATCH 03/77] =?UTF-8?q?*feat*:=20=EB=8B=A8=EC=B6=95=20url=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/url_shorten_checker.py | 48 +++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/analyzers/url_shorten_checker.py b/analyzers/url_shorten_checker.py index e5397d1..945a0bb 100644 --- a/analyzers/url_shorten_checker.py +++ b/analyzers/url_shorten_checker.py @@ -1 +1,47 @@ -# 단축 URL 여부 검사. 단, 해당 부분은 가장 먼저 검사하므로 파이프라인 내지 메인에 해당 코드가 삽입될 가능성 존재함 +# 단축 URL 여부 검사 +# 정규화된 Hostname을 받아 단축 URL 서비스 도메인과 일치 시, 단축 URL로 판별합니다. + +# hostname이 단축 URL 서비스 도메인인지 검사 +def detect_shortened_url(normalized_hostname: str) -> dict: + + hostname = normalized_hostname # 검사 대상 hostname + is_shortened_url = False # 단축 URL 여부 + matched_shortener = "" # 매칭된 단축 URL 서비스 도메인 + + # hostname 유효성 확인 + if hostname is None or hostname == "": + return { + "is_shortened_url": False, + "matched_shortener": "", + } + + # 단축 URL 도메인 목록과 비교 + if hostname in SHORTENER_DOMAINS: + is_shortened_url = True + matched_shortener = hostname + + return { + "is_shortened_url": is_shortened_url, + "matched_shortener": matched_shortener, + } + + +# 단축 URL 서비스 도메인 목록 +SHORTENER_DOMAINS = { + "bit.ly", + "tinyurl.com", + "t.co", + "goo.gl", + "ow.ly", + "is.gd", + "buff.ly", + "adf.ly", + "bit.do", + "cutt.ly", + "url.kr", + "c11.kr", + "han.gl", + "me2.do", + "vo.la", + "lc.chat", +} \ No newline at end of file From 14adf70b687322740607cd733165699b60935225 Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Wed, 8 Apr 2026 09:22:13 +0900 Subject: [PATCH 04/77] chore/add-docker file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도커 자동 파이프 라인 구축 위한 도커 파일 추가 --- Dockerfile | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c28134c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.9-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] From c23582e7d570c66ef34e865fd4014f9e80978820 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Thu, 9 Apr 2026 05:36:56 +0900 Subject: [PATCH 05/77] =?UTF-8?q?*feat*:=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=20=EC=B6=94=EC=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .vscode/settings.json | 3 + analyzers/redirect_tracker.py | 186 ++++++++++++++++++++++++++++++++++ main.py | 15 ++- 4 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 .vscode/settings.json diff --git a/.gitignore b/.gitignore index b7faf40..0cfe250 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ venv/ ENV/ env.bak/ venv.bak/ +.vscode # Spyder project settings .spyderproject diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..ca6fe06 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/analyzers/redirect_tracker.py b/analyzers/redirect_tracker.py index e69de29..5649b91 100644 --- a/analyzers/redirect_tracker.py +++ b/analyzers/redirect_tracker.py @@ -0,0 +1,186 @@ +# 리다이렉트 추적 검사 +import requests +from urllib.parse import urljoin + +REQUEST_TIMEOUT = 5 + +STATUS_SUCCESS = "success" +STATUS_CLIENT_ERROR = "client_error" +STATUS_REDIRECT = "redirect" +STATUS_REQUEST_FAILED = "request_failed" +STATUS_LOOP_DETECTED = "loop_detected" +STATUS_TOO_MANY_REDIRECTS = "too_many_redirects" +STATUS_INVALID_LOCATION = "invalid_location" + +# 상태코드 분류 +# 3xx+Location을 리다이렉트로 분류 +def classify_response_status(status_code: int, location: str | None) -> str: + if 200 <= status_code < 300: + return STATUS_SUCCESS + + if 300 <= status_code < 400: + if location: + return STATUS_REDIRECT + return STATUS_REQUEST_FAILED + + if 400 <= status_code < 500: + return STATUS_CLIENT_ERROR + + return STATUS_REQUEST_FAILED + + +# 현 URL에 한 번 요청, 자동 리다이렉트 X +def request_once(current_url: str) -> dict: + + try: + response = requests.get( + current_url, + allow_redirects=False, + timeout=REQUEST_TIMEOUT + ) + + location = response.headers.get("Location") + status_type = classify_response_status(response.status_code, location) + + return { + "status_code": response.status_code, + "location": location, + "status_type": status_type, + "error_message": None + } + + except requests.exceptions.RequestException as e: + return { + "status_code": None, + "location": None, + "status_type": STATUS_REQUEST_FAILED, + "error_message": str(e) + } + + +# 현 URL+Location 헤더로 이동할 URL 생성 +# location 존재 X->None, 상대 경로는 현재 URL 기준 변환 +def build_next_url(current_url: str, location: str | None) -> str | None: + if not location: + return None + return urljoin(current_url, location) + + +# 리다이렉트 추적 결과 반환 +def build_trace_result( + original_url: str, + final_url: str | None, + redirect_count: int, + trace_status: str, + status_code: int | None = None, + error_message: str | None = None +) -> dict: + return { + "original_url": original_url, + "final_url": final_url, + "is_redirected": redirect_count > 0, + "redirect_count": redirect_count, + "trace_status": trace_status, + "status_code": status_code, + "error_message": error_message + } + + +def trace_redirect(start_url: str, max_hops: int = 30) -> dict: + + original_url = start_url + current_url = start_url + + visited_urls = set() + redirect_count = 0 + + while True: + # 루프 검사 + if current_url in visited_urls: + return build_trace_result( + original_url, + current_url, + redirect_count, + STATUS_LOOP_DETECTED, + None, + "Redirect loop detected" + ) + + # 방문했던 URL 리스트에 현재 URL 추가 + visited_urls.add(current_url) + + # 리다이렉트 요청 + response_info = request_once(current_url) + + status_code = response_info["status_code"] + location = response_info["location"] + status_type = response_info["status_type"] + error_message = response_info["error_message"] + + #---------- 리다이렉트 추적 + if status_type == STATUS_REDIRECT: + next_url = build_next_url(current_url, location) + + if not next_url: + return build_trace_result( + original_url, + current_url, + redirect_count, + STATUS_INVALID_LOCATION, + status_code, + "Location header is missing or invalid" + ) + + redirect_count += 1 + + if redirect_count > max_hops: + return build_trace_result( + original_url, + current_url, + redirect_count, + STATUS_TOO_MANY_REDIRECTS, + status_code, + f"Redirect count exceeded max_hops={max_hops}" + ) + + current_url = next_url + continue + + #---------- 추적 종료 + if status_type == STATUS_SUCCESS: + return build_trace_result( + original_url, + current_url, + redirect_count, + STATUS_SUCCESS, + status_code + ) + + if status_type == STATUS_CLIENT_ERROR: + return build_trace_result( + original_url, + current_url, + redirect_count, + STATUS_CLIENT_ERROR, + status_code + ) + + if status_type == STATUS_REQUEST_FAILED and status_code is None: + return build_trace_result( + original_url, + current_url, + redirect_count, + STATUS_REQUEST_FAILED, + None, + error_message + ) + + # 기타 + return build_trace_result( + original_url, + current_url, + redirect_count, + STATUS_REQUEST_FAILED, + status_code, + f"Unhandled status code: {status_code}" + ) \ No newline at end of file diff --git a/main.py b/main.py index 3c49f13..0d426ee 100644 --- a/main.py +++ b/main.py @@ -6,8 +6,9 @@ from fastapi.responses import StreamingResponse from services.sse import event_generator, send_step -from analyzers.req_api import check_url_safe_browsing +from analyzers import check_url_safe_browsing from analyzers.req_db import check_url_exists +from analyzers.redirect_tracker import trace_redirect app = FastAPI() @@ -50,4 +51,14 @@ def test_db(url: str): # SSE 엔드포인트 @app.get("/stream") def stream(): - return StreamingResponse(event_generator(), media_type="text/event-stream") \ No newline at end of file + return StreamingResponse(event_generator(), media_type="text/event-stream") + +# 리다이렉트 추적 테스트 +@app.post("/test/redirect") +def test_redirect_trace(url: str): + result = trace_redirect(url) + + return { + "message": "redirect trace completed", + "redirect_result": result + } \ No newline at end of file From 18e690062b24a44795e8a4318f704731ec634481 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Thu, 9 Apr 2026 06:09:34 +0900 Subject: [PATCH 06/77] requirements.txt --- requirements.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4d5133e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,15 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +click==8.3.2 +colorama==0.4.6 +fastapi==0.135.3 +h11==0.16.0 +idna==3.11 +pydantic==2.12.5 +pydantic_core==2.41.5 +python-dotenv==1.2.2 +starlette==1.0.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.44.0 From 2c21aed7c3e60974748c1f6d0bd604a3506f409e Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Thu, 9 Apr 2026 12:28:35 +0900 Subject: [PATCH 07/77] =?UTF-8?q?fix-Dockerfile:=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=EC=8D=AC=20=EB=B2=84=EC=A0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c28134c..f8fda52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-slim +FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt From 1a28f26878b1a9e065456bc236e07d8dd9f4a1c9 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 10 Apr 2026 03:48:31 +0900 Subject: [PATCH 08/77] =?UTF-8?q?=EB=A3=B0=20=EA=B8=B0=EB=B0=98=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D/main=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 25 +++++++ analyzers/parameter_rule.py | 135 +++++++++++++++++++++++++++++++++++ docker-compose.yml | 64 +++++++++++++++++ main.py | 2 +- 4 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 analyzers/parameter_rule.py create mode 100644 docker-compose.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..9a7d7d0 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,25 @@ +name: FastAPI Auto Deploy + +on: + push: + branches: [ "develop" ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: SSH Remote Commands for FastAPI + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.GCP_IP }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + script: | + # 1. FastAPI 폴더로 이동해서 최신 코드 pull + cd /home/sk38808738/analysis-engine + git pull origin develop + # 2. 도커 설계도가 있는 메인 서버 폴더로 이동 + cd /home/sk38808738/main-server + sudo docker-compose up --build -d veriq-analysis + + diff --git a/analyzers/parameter_rule.py b/analyzers/parameter_rule.py new file mode 100644 index 0000000..25ee581 --- /dev/null +++ b/analyzers/parameter_rule.py @@ -0,0 +1,135 @@ +# 룰 기반의 파라미터 분석 로직입니다. +# query 파라미터, path, 인코딩 등을 분석해 의심스러운 사항을 기록합니다. +import re +from urllib.parse import unquote +from urllib.parse import parse_qsl, unquote + +FLAG_PERCENT_ENCODING = "percent_encoding_detected" +FLAG_DOUBLE_ENCODING = "double_encoding_suspected" + +FLAG_SUSPICIOUS_QUERY_PARAM = "suspicious_query_param_detected" +FLAG_EMBEDDED_URL_IN_QUERY = "embedded_url_in_query" +FLAG_SUSPICIOUS_QUERY_KEYWORD = "suspicious_query_keyword_detected" +SUSPICIOUS_QUERY_PARAM_NAMES = { + "url", "redirect", "redir", "next", "continue", + "target", "dest", "destination", "return", + "returnurl", "return_url", "callback" +} +SUSPICIOUS_KEYWORDS = { + "login", "signin", "verify", "verification", + "secure", "account", "update", "bank", + "payment", "confirm", "wallet", "auth" +} +FLAG_SUSPICIOUS_PATH_KEYWORD = "suspicious_path_keyword_detected" +FLAG_SUSPICIOUS_FRAGMENT_KEYWORD = "suspicious_fragment_keyword_detected" + + +PERCENT_ENCODING_PATTERN = re.compile(r"%[0-9A-Fa-f]{2}") +# 인코딩 검사- 퍼센트 인코딩 여부, 이중 인코딩 의심 여부를 분석 +def analyze_encoding(path: str, query: str, fragment: str, result: dict) -> None: + raw_text = f"{path}{query}{fragment}" + + # 퍼센트 인코딩 여부 + if PERCENT_ENCODING_PATTERN.search(raw_text): + if FLAG_PERCENT_ENCODING not in result["flags"]: + result["flags"].append(FLAG_PERCENT_ENCODING) + + # 1회 decode 후에도 %xx 패턴이 남으면 이중 인코딩 의심 + decoded_once = unquote(raw_text) + + if PERCENT_ENCODING_PATTERN.search(raw_text) and PERCENT_ENCODING_PATTERN.search(decoded_once): + if FLAG_DOUBLE_ENCODING not in result["flags"]: + result["flags"].append(FLAG_DOUBLE_ENCODING) + + +# query 문자열 분석 +def analyze_query(query: str, result: dict) -> None: + query_pairs = parse_qsl(query, keep_blank_values=True) + + for raw_key, raw_value in query_pairs: + key = raw_key.lower() + value = raw_value + decoded_value = unquote(value) + lower_value = decoded_value.lower() + + # 의심되는 파라미터 값 포함 여부 확인 + if key in SUSPICIOUS_QUERY_PARAM_NAMES: + if raw_key not in result["suspicious_query_params"]: + result["suspicious_query_params"].append(raw_key) + + if FLAG_SUSPICIOUS_QUERY_PARAM not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_QUERY_PARAM) + + # query값에 내포된 URL 존재여부 검사 + if ( + "http://" in lower_value or + "https://" in lower_value or + "www." in lower_value + ): + if decoded_value not in result["embedded_urls_in_query"]: + result["embedded_urls_in_query"].append(decoded_value) + + if FLAG_EMBEDDED_URL_IN_QUERY not in result["flags"]: + result["flags"].append(FLAG_EMBEDDED_URL_IN_QUERY) + + # query 값 안의 의심 키워드 검사 + for keyword in SUSPICIOUS_KEYWORDS: + if keyword in lower_value: + if keyword not in result["suspicious_keywords"]["query"]: + result["suspicious_keywords"]["query"].append(keyword) + + if FLAG_SUSPICIOUS_QUERY_KEYWORD not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_QUERY_KEYWORD) + + +# path 안의 의심 키워드 검사 +def analyze_path(path: str, result: dict) -> None: + lower_path = unquote(path).lower() + + for keyword in SUSPICIOUS_KEYWORDS: + if keyword in lower_path: + if keyword not in result["suspicious_keywords"]["path"]: + result["suspicious_keywords"]["path"].append(keyword) + + if FLAG_SUSPICIOUS_PATH_KEYWORD not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_PATH_KEYWORD) + + +# fragment 안의 의심 키워드 검사 +def analyze_fragment(fragment: str, result: dict) -> None: + lower_fragment = unquote(fragment).lower() + + for keyword in SUSPICIOUS_KEYWORDS: + if keyword in lower_fragment: + if keyword not in result["suspicious_keywords"]["fragment"]: + result["suspicious_keywords"]["fragment"].append(keyword) + + if FLAG_SUSPICIOUS_FRAGMENT_KEYWORD not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_FRAGMENT_KEYWORD) + + +# 룰 기반 검사 수행 함수 +# utl_utils에서 정규화가 끝난 결과를 받아 룰 기반 분석을 진행합니다ㅣ. +def analyze_url_rules(normalized_url: str, path: str, query: str, fragment: str) -> dict: + + result = { + "flags": [], + "embedded_urls_in_query": [], + "suspicious_query_params": [], + "suspicious_keywords": { + "path": [], + "query": [], + "fragment": [] + }, + "url_length": len(normalized_url) if normalized_url else 0, + "path_length": len(path) if path else 0, + "query_length": len(query) if query else 0, + "fragment_length": len(fragment) if fragment else 0 + } + + analyze_encoding(path, query, fragment, result) + analyze_query(query, result) + analyze_path(path, result) + analyze_fragment(fragment, result) + + return result \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..147ee65 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +services: + # 1. MySQL 데이터베이스 + veriq-db: + image: mysql:8.0 + container_name: veriq-db + restart: always + ports: + - "127.0.0.1:3306:3306" # 외부 IP 접속 차단, 로컬 호스트만 허용 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: veriq_db + volumes: + - ./mysql_data:/var/lib/mysql + + # 2. Redis 캐시 서버 + veriq-redis: + image: redis:latest + container_name: veriq-redis + restart: always + ports: + - "127.0.0.1:6379:6379" + + # 3. Spring BE1 (Gateway) + veriq-gateway: + build: + context: . + dockerfile: Dockerfile + container_name: veriq-be1 + restart: always + ports: + - "8081:8081" + environment: + SPRING_PROFILES_ACTIVE: be1 + BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" # 도커 내부망 주소로 덮어쓰기 + depends_on: + - veriq-db + - veriq-redis + + # 4. Spring BE3 (Platform/Data Hub) + veriq-platform: + build: + context: . + dockerfile: Dockerfile + container_name: veriq-be3 + restart: always + ports: + - "8083:8083" + environment: + SPRING_PROFILES_ACTIVE: be3 + depends_on: + - veriq-db + - veriq-redis + + # 5. FastAPI (분석 엔진) + veriq-analysis: + build: + context: ../analysis-engine + dockerfile: Dockerfile + container_name: veriq-analysis + restart: always + ports: + - "8000:8000" + depends_on: + - veriq-db diff --git a/main.py b/main.py index 0d426ee..f7ea4f4 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,7 @@ from fastapi.responses import StreamingResponse from services.sse import event_generator, send_step -from analyzers import check_url_safe_browsing +from analyzers.req_api import check_url_safe_browsing from analyzers.req_db import check_url_exists from analyzers.redirect_tracker import trace_redirect From 830867477808b588e43852d0acafe3bd5518265f Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Fri, 10 Apr 2026 21:14:45 +0900 Subject: [PATCH 09/77] fix-deploy.yml --- .github/workflows/deploy.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 9a7d7d0..90d8161 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,11 +15,14 @@ jobs: username: ${{ secrets.GCP_USER }} key: ${{ secrets.GCP_SSH_KEY }} script: | - # 1. FastAPI 폴더로 이동해서 최신 코드 pull - cd /home/sk38808738/analysis-engine + # 1. 파이썬 공용 폴더로 이동해서 최신 코드 땡겨오기 + cd /opt/analysis-engine git pull origin develop - # 2. 도커 설계도가 있는 메인 서버 폴더로 이동 - cd /home/sk38808738/main-server + + # 2. 도커 설계도가 있는 메인 서버 폴더로 이동! + cd /opt/main-server + + # 3. 파이썬(veriq-analysis) 컨테이너만 콕 집어서 다시 빌드하고 실행 sudo docker-compose up --build -d veriq-analysis From 6daedc9fcf1f7d2af814b427bbc4b972854f6fb0 Mon Sep 17 00:00:00 2001 From: Park Sang Min Date: Sat, 11 Apr 2026 01:43:44 +0900 Subject: [PATCH 10/77] chore/add-docker file Increase command timeout for deployment script. --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 90d8161..395749f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,6 +14,7 @@ jobs: host: ${{ secrets.GCP_IP }} username: ${{ secrets.GCP_USER }} key: ${{ secrets.GCP_SSH_KEY }} + command_timeout: 30m script: | # 1. 파이썬 공용 폴더로 이동해서 최신 코드 땡겨오기 cd /opt/analysis-engine From d5efa57a85fcad3e4b1fb6b5d3bf624901b94ab0 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 05:25:01 +0900 Subject: [PATCH 11/77] =?UTF-8?q?*feat/refactor*:=201=EC=B0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B4=80=EB=A0=A8=20-=20pipeline=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=EA=B9=8C=EC=A7=80=20=EA=B5=AC=ED=98=84,=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=ED=86=B5=EC=9D=BC=EC=84=B1=20=ED=96=A5=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/ai_analyze.py | 54 ++++++- analyzers/parameter_rule.py | 52 ++++--- analyzers/redirect_tracker.py | 245 ++++++++++++++++--------------- analyzers/req_api.py | 166 ++++++++++++++------- analyzers/req_db.py | 74 ++++++---- analyzers/url_shorten_checker.py | 51 +++++-- core/base.py | 2 - core/context.py | 124 ++++++++++++++++ core/pipeline.py | 97 +++++++++++- main.py | 2 + models/base.py | 13 ++ models/response.py | 106 ------------- models/result.py | 90 ++++++------ services/builder.py | 103 +++++++++++++ services/json_builder.py | 2 - services/response.py | 1 + services/scoring.py | 197 +++++++++++++++++++++++++ services/url_utils.py | 98 +++++-------- 18 files changed, 1024 insertions(+), 453 deletions(-) delete mode 100644 core/base.py create mode 100644 models/base.py delete mode 100644 models/response.py create mode 100644 services/builder.py delete mode 100644 services/json_builder.py create mode 100644 services/response.py create mode 100644 services/scoring.py diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py index bcf8923..acee385 100644 --- a/analyzers/ai_analyze.py +++ b/analyzers/ai_analyze.py @@ -1,4 +1,50 @@ -# ai를 이용하는 모든 부분을 해당 클래스에 선언. -# userinfo, host, domain등.... 가장 많은 파츠를 보지 않나 -# 스코어링도 ai로 한다면 여기에 클래스 정의하게 됨 -# scoring engine을 별도 분리할 생각이 그다지 없긴 한데... 아무튼간에. +# pre-trained된 ML모델을 호출해 분석을 진행합니다. +# ML 기반 검사 로직입니다. +# 현재는 학습된 모델 대신 더미 추론 결과를 반환합니다. +from core.context import AnalysisContext + + +# 메인 함수 +# context.redirect.final_url을 입력으로 사용하여 ML 결과 dict를 반환 +def ai_analyze(context: AnalysisContext) -> dict: + final_url = context.redirect.get("final_url") + + result = run_ml_inference(final_url) + + return build_ml_result( + score=result["score"], + threats=result["threats"] + ) + + +# 학습된 모델 호출 자리 +# 현재는 더미 결과를 반환 +def run_ml_inference(final_url: str | None) -> dict: + if not final_url: + return { + "score": 0.0, + "threats": [] + } + + # TODO: + # - FEB feature extraction + # - XGB 모델 로드 + # - predict / predict_proba 수행 + # - 위협 요소 추출 + + # 현재는 더미 반환 + return { + "score": 0.61, + "threats": ["dummy_ml_threat"] + } + + +# ML 결과 dict 조립 +def build_ml_result(score: float = 0.0, threats: list | None = None) -> dict: + if threats is None: + threats = [] + + return { + "score": score, + "threats": threats + } \ No newline at end of file diff --git a/analyzers/parameter_rule.py b/analyzers/parameter_rule.py index 25ee581..b865ace 100644 --- a/analyzers/parameter_rule.py +++ b/analyzers/parameter_rule.py @@ -1,14 +1,15 @@ # 룰 기반의 파라미터 분석 로직입니다. # query 파라미터, path, 인코딩 등을 분석해 의심스러운 사항을 기록합니다. import re -from urllib.parse import unquote from urllib.parse import parse_qsl, unquote +from core.context import AnalysisContext + FLAG_PERCENT_ENCODING = "percent_encoding_detected" FLAG_DOUBLE_ENCODING = "double_encoding_suspected" FLAG_SUSPICIOUS_QUERY_PARAM = "suspicious_query_param_detected" -FLAG_EMBEDDED_URL_IN_QUERY = "embedded_url_in_query" +FLAG_EMBEDDED_URL_IN_QUERY = "embedded_url" FLAG_SUSPICIOUS_QUERY_KEYWORD = "suspicious_query_keyword_detected" SUSPICIOUS_QUERY_PARAM_NAMES = { "url", "redirect", "redir", "next", "continue", @@ -23,8 +24,27 @@ FLAG_SUSPICIOUS_PATH_KEYWORD = "suspicious_path_keyword_detected" FLAG_SUSPICIOUS_FRAGMENT_KEYWORD = "suspicious_fragment_keyword_detected" - PERCENT_ENCODING_PATTERN = re.compile(r"%[0-9A-Fa-f]{2}") + + + +# 룰 기반 검사 수행 함수 +# context.url에서 필요한 값을 읽어 rule_based 형식의 dict를 반환 +def parameter_rule(context: AnalysisContext) -> dict: + path = context.url.get("path", "") + query = context.url.get("query", "") + fragment = context.url.get("fragment", "") + + result = build_rule_based_result() + + analyze_encoding(path, query, fragment, result) + analyze_query(query, result) + analyze_path(path, result) + analyze_fragment(fragment, result) + + return result + + # 인코딩 검사- 퍼센트 인코딩 여부, 이중 인코딩 의심 여부를 분석 def analyze_encoding(path: str, query: str, fragment: str, result: dict) -> None: raw_text = f"{path}{query}{fragment}" @@ -48,8 +68,7 @@ def analyze_query(query: str, result: dict) -> None: for raw_key, raw_value in query_pairs: key = raw_key.lower() - value = raw_value - decoded_value = unquote(value) + decoded_value = unquote(raw_value) lower_value = decoded_value.lower() # 의심되는 파라미터 값 포함 여부 확인 @@ -108,28 +127,15 @@ def analyze_fragment(fragment: str, result: dict) -> None: result["flags"].append(FLAG_SUSPICIOUS_FRAGMENT_KEYWORD) -# 룰 기반 검사 수행 함수 -# utl_utils에서 정규화가 끝난 결과를 받아 룰 기반 분석을 진행합니다ㅣ. -def analyze_url_rules(normalized_url: str, path: str, query: str, fragment: str) -> dict: - - result = { +# rule_based 형식의 결과 dict 조립 +def build_rule_based_result() -> dict: + return { "flags": [], - "embedded_urls_in_query": [], "suspicious_query_params": [], + "embedded_urls_in_query": [], "suspicious_keywords": { "path": [], "query": [], "fragment": [] }, - "url_length": len(normalized_url) if normalized_url else 0, - "path_length": len(path) if path else 0, - "query_length": len(query) if query else 0, - "fragment_length": len(fragment) if fragment else 0 - } - - analyze_encoding(path, query, fragment, result) - analyze_query(query, result) - analyze_path(path, result) - analyze_fragment(fragment, result) - - return result \ No newline at end of file + } \ No newline at end of file diff --git a/analyzers/redirect_tracker.py b/analyzers/redirect_tracker.py index 5649b91..9645071 100644 --- a/analyzers/redirect_tracker.py +++ b/analyzers/redirect_tracker.py @@ -1,8 +1,11 @@ # 리다이렉트 추적 검사 import requests from urllib.parse import urljoin +from core.context import AnalysisContext + REQUEST_TIMEOUT = 5 +MAX_REDIRECT_HOPS = 30 STATUS_SUCCESS = "success" STATUS_CLIENT_ERROR = "client_error" @@ -12,6 +15,113 @@ STATUS_TOO_MANY_REDIRECTS = "too_many_redirects" STATUS_INVALID_LOCATION = "invalid_location" + + +def redirect_tracker(context: AnalysisContext) -> dict: + # 단축 URL : 단축 URL을 시작 URL로 함 + # 일반 URL : 정규화를 거친 URL을 시작 URL로 함 + if context.flags.get("is_shortened_url"): + start_url = context.request.get("original_url") + else: + start_url = context.url.get("normalized_url") + + current_url = start_url + visited_urls = set() + redirect_count = 0 + redirect_chain = [] + + while True: + # 루프 검사 + if current_url in visited_urls: + redirect_chain.append({ + "url": current_url, + "status_code": None, + "status_label": STATUS_LOOP_DETECTED, + "location": None + }) + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + loop_detected=True, + status_type=[None, STATUS_LOOP_DETECTED], + result_type=False + ) + + # 방문했던 URL 리스트에 현재 URL 추가 + visited_urls.add(current_url) + + # 리다이렉트 요청 + response_info = request_once(current_url) + + status_code = response_info["status_code"] + location = response_info["location"] + status_type = response_info["status_type"] + + redirect_chain.append({ + "url": current_url, + "status_code": status_code, + "status_label": status_type, + "location": location + }) + + #---------- 리다이렉트 추적 + if status_type == STATUS_REDIRECT: + next_url = build_next_url(current_url, location) + + if not next_url: + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_INVALID_LOCATION], + result_type=False + ) + + redirect_count += 1 + + if redirect_count > MAX_REDIRECT_HOPS: + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_TOO_MANY_REDIRECTS], + result_type=False + ) + + current_url = next_url + continue + + #---------- 추적 종료 + if status_type == STATUS_SUCCESS: + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_SUCCESS], + result_type=True + ) + + # 클라이언트 에러 종료 + if status_type == STATUS_CLIENT_ERROR: + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_CLIENT_ERROR], + result_type=False + ) + + # 요청 실패 / 기타 종료 + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_REQUEST_FAILED], + result_type=False + ) + + # 상태코드 분류 # 3xx+Location을 리다이렉트로 분류 def classify_response_status(status_code: int, location: str | None) -> str: @@ -31,7 +141,6 @@ def classify_response_status(status_code: int, location: str | None) -> str: # 현 URL에 한 번 요청, 자동 리다이렉트 X def request_once(current_url: str) -> dict: - try: response = requests.get( current_url, @@ -46,7 +155,6 @@ def request_once(current_url: str) -> dict: "status_code": response.status_code, "location": location, "status_type": status_type, - "error_message": None } except requests.exceptions.RequestException as e: @@ -54,9 +162,8 @@ def request_once(current_url: str) -> dict: "status_code": None, "location": None, "status_type": STATUS_REQUEST_FAILED, - "error_message": str(e) } - + # 현 URL+Location 헤더로 이동할 URL 생성 # location 존재 X->None, 상대 경로는 현재 URL 기준 변환 @@ -66,121 +173,21 @@ def build_next_url(current_url: str, location: str | None) -> str | None: return urljoin(current_url, location) -# 리다이렉트 추적 결과 반환 -def build_trace_result( - original_url: str, - final_url: str | None, - redirect_count: int, - trace_status: str, - status_code: int | None = None, - error_message: str | None = None -) -> dict: - return { - "original_url": original_url, - "final_url": final_url, - "is_redirected": redirect_count > 0, - "redirect_count": redirect_count, - "trace_status": trace_status, - "status_code": status_code, - "error_message": error_message +# context.redirect 형식에 맞춘 결과 dict 조립 +def build_redirect_result(**overrides) -> dict: + result = { + "was_checked": True, + "redirect_count": 0, + "final_url": None, + "redirect_chain": [], + "loop_detected": False, + "status_type": [None, None], + "result_type": None } + for key in overrides: + if key not in result: + raise KeyError(f"허용되지 않은 redirect 필드: {key}") -def trace_redirect(start_url: str, max_hops: int = 30) -> dict: - - original_url = start_url - current_url = start_url - - visited_urls = set() - redirect_count = 0 - - while True: - # 루프 검사 - if current_url in visited_urls: - return build_trace_result( - original_url, - current_url, - redirect_count, - STATUS_LOOP_DETECTED, - None, - "Redirect loop detected" - ) - - # 방문했던 URL 리스트에 현재 URL 추가 - visited_urls.add(current_url) - - # 리다이렉트 요청 - response_info = request_once(current_url) - - status_code = response_info["status_code"] - location = response_info["location"] - status_type = response_info["status_type"] - error_message = response_info["error_message"] - - #---------- 리다이렉트 추적 - if status_type == STATUS_REDIRECT: - next_url = build_next_url(current_url, location) - - if not next_url: - return build_trace_result( - original_url, - current_url, - redirect_count, - STATUS_INVALID_LOCATION, - status_code, - "Location header is missing or invalid" - ) - - redirect_count += 1 - - if redirect_count > max_hops: - return build_trace_result( - original_url, - current_url, - redirect_count, - STATUS_TOO_MANY_REDIRECTS, - status_code, - f"Redirect count exceeded max_hops={max_hops}" - ) - - current_url = next_url - continue - - #---------- 추적 종료 - if status_type == STATUS_SUCCESS: - return build_trace_result( - original_url, - current_url, - redirect_count, - STATUS_SUCCESS, - status_code - ) - - if status_type == STATUS_CLIENT_ERROR: - return build_trace_result( - original_url, - current_url, - redirect_count, - STATUS_CLIENT_ERROR, - status_code - ) - - if status_type == STATUS_REQUEST_FAILED and status_code is None: - return build_trace_result( - original_url, - current_url, - redirect_count, - STATUS_REQUEST_FAILED, - None, - error_message - ) - - # 기타 - return build_trace_result( - original_url, - current_url, - redirect_count, - STATUS_REQUEST_FAILED, - status_code, - f"Unhandled status code: {status_code}" - ) \ No newline at end of file + result.update(overrides) + return result \ No newline at end of file diff --git a/analyzers/req_api.py b/analyzers/req_api.py index 2782ba5..fb62eca 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -4,12 +4,126 @@ import requests import time +from core.context import AnalysisContext + load_dotenv() -API_KEY = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY") +# GSB = google safe browsing +GSB_CODE = "GSB" +GSB_API_KEY = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY") + +STATUS_SUCCESS = "success" +STATUS_TIMEOUT = "timeout" +STATUS_REQUEST_FAILED = "request_failed" +STATUS_INVALID_INPUT = "invalid_input" +STATUS_CLIENT_ERROR = "client_error" +STATUS_REQUEST_FAILED = "request_failed" + + + +# 메인 함수 +# pipeline에서 넘겨준 target_url을 기준으로 Safe Browsing 조회 수행 +def req_api(context:AnalysisContext) -> dict: + target_url=context.url.get("noramlized_url") + + raw = GSB_req_api(target_url) + parsed = parse_GSB_result(raw) + + return build_api_result("safe_browsing", parsed) + + +# 요청 본문 +def GSB_req_api(url: str | None) -> dict: + endpoint = f"https://safebrowsing.googleapis.com/v4/threatMatches:find?key={GSB_API_KEY}" + body = GSB_req_build(url) + + start = time.time() + + try: + response = requests.post(endpoint, json=body, timeout=5) + elapsed = time.time() - start + + return { # TODO: 정상적으로 처리되는지 확인 필요 + "type": STATUS_SUCCESS, + "status_code": response.status_code, + "data": response.json() if response.status_code == 200 else None, + "elapsed": elapsed + } + + except requests.exceptions.Timeout: + return {"type": STATUS_TIMEOUT} + + except requests.exceptions.RequestException: + return {"type": STATUS_REQUEST_FAILED} + + +def parse_GSB_result(raw: dict) -> dict: + rtype = raw.get("type") + + # 실패 계열 + if rtype != STATUS_SUCCESS: + return { + "is_threat": False, + "status_type": [None, rtype], + "result_type": False, + "threat_labels": [] + } + + # HTTP 상태 체크 + status_code = raw.get("status_code") + if status_code != 200: + return { + "is_threat": False, + "status_type": [status_code, STATUS_CLIENT_ERROR], + "result_type": False, + "threat_labels": [] + } + + data = raw.get("data", {}) + matches = data.get("matches", []) + + if not matches: + return { + "is_threat": False, + "status_type": [200, STATUS_SUCCESS], + "result_type": True, + "threat_labels": [] + } + + # 위협 존재 + threat_types = list(set(m["threatType"] for m in matches)) + + labels = [f"{GSB_CODE}:{t}" for t in threat_types] -#요청 형식 -def build_request_body(url: str): + return { + "is_threat": True, + "status_type": [200, STATUS_SUCCESS], + "result_type": True, + "threat_labels": labels + } + +# ================= 최종 결과 조립 ================= + +def build_api_result(name: str, parsed: dict) -> dict: + base = { + name: { + "is_threat": False, + "status_type": None, + "result_type": None, + "threat_labels": [] + }, + "report_count": None, + "block_count": None, + "domain_age": None + } + + base[name].update(parsed) + return base + +# ============= 요청 body 빌드 함수 =============== + +# GSB 요청 body 생성 +def GSB_req_build(url: str): return { "client": { "clientId": "url-analysis-query", @@ -29,49 +143,3 @@ def build_request_body(url: str): ] } } - -#요청 본문 -def check_url_safe_browsing(url: str): - endpoint = f"https://safebrowsing.googleapis.com/v4/threatMatches:find?key={API_KEY}" - - body = build_request_body(url) - - start = time.time() - - try: - response = requests.post(endpoint, json=body, timeout=5) - elapsed = time.time() - start - - if response.status_code != 200: #### 200이 아님 이 아니라 문서 규약에 따라 400/404/401 등 응답 반환으로 조정필요 - return { - "error": True, - "status_code": response.status_code, - "elapsed": elapsed - } - - data = response.json() - - matches = data.get("matches", []) - - # 전반적으로 if-return이 아니라 exept throwing방식이 더 나을 것 같음 - # 기본 케이스는 조회 실패. - # exept로 200을 포함한 상태코드로 설정 - return { - "matched": len(matches) > 0, - "threat_types": list(set([m["threatType"] for m in matches])) if matches else [], - "elapsed": elapsed, - "raw": data - } - - except requests.exceptions.Timeout: # 이건 상태코드가 설정이 되어있지 않음 - return {"error": "timeout"} - except Exception as e: # 그냥 에러 말고 어떤 에러인지도 좀 써야 할 것 같다. - return {"error": str(e)} - - -#테스트용 코드 -if __name__ == "__main__": - test_url = "http://malware.testing.google.test/testing/malware/" - - result = check_url_safe_browsing(test_url) - print(result) diff --git a/analyzers/req_db.py b/analyzers/req_db.py index 0a32ff7..36e30a2 100644 --- a/analyzers/req_db.py +++ b/analyzers/req_db.py @@ -1,38 +1,56 @@ # 내부 DB 조회 -import requests -import os -import time +import pymysql +from core.context import AnalysisContext -DB_KEY = os.getenv("DB_API_URL") +# 메인 함수 +def req_db(context: AnalysisContext) -> dict: + url = context.url.get("normalized_url") -def check_url_exists(url: str): - start = time.time() + row = select_url_record(url) - try: - response = requests.post( - DB_KEY, - json={"url": url}, - timeout=5 + if row: + return build_db_result( + exists=True, + data=row ) - elapsed = time.time() - start + return build_db_result( + exists=False, + data=None + ) - if response.status_code != 200: - return { - "error": True, - "status_code": response.status_code, - "elapsed": elapsed - } - data = response.json() +# 실제 DB 조회 +def select_url_record(url: str | None): + if not url: + return None - return { - "exists": data.get("exists"), - "id": data.get("id"), - "elapsed": elapsed - } + conn = pymysql.connect( # env 보고 키 적용해서 변경 + host="DB_HOST", + user="DB_USER", + password="DB_PASSWORD", + database="DB_NAME", + charset="utf8" + ) - except requests.exceptions.Timeout: - return {"error": "timeout"} - except Exception as e: - return {"error": str(e)} + try: + with conn.cursor(pymysql.cursors.DictCursor) as cursor: + cursor.execute( + "SELECT * FROM url_analysis WHERE final_url = %s LIMIT 1", + (url,) + ) + return cursor.fetchone() + + finally: + conn.close() + + +# 결과 조립 +def build_db_result(**overrides) -> dict: + result = { + "exists": False, + "data": None + } + + result.update(overrides) + return result \ No newline at end of file diff --git a/analyzers/url_shorten_checker.py b/analyzers/url_shorten_checker.py index 945a0bb..a37122f 100644 --- a/analyzers/url_shorten_checker.py +++ b/analyzers/url_shorten_checker.py @@ -1,31 +1,56 @@ # 단축 URL 여부 검사 # 정규화된 Hostname을 받아 단축 URL 서비스 도메인과 일치 시, 단축 URL로 판별합니다. +from core.context import AnalysisContext + +# 메인 함수 # hostname이 단축 URL 서비스 도메인인지 검사 -def detect_shortened_url(normalized_hostname: str) -> dict: +def url_shorten_checker(context: AnalysisContext) -> dict: + + hostname = context.url.get["hostname"] + return build_shorten_result(hostname) - hostname = normalized_hostname # 검사 대상 hostname - is_shortened_url = False # 단축 URL 여부 - matched_shortener = "" # 매칭된 단축 URL 서비스 도메인 - # hostname 유효성 확인 - if hostname is None or hostname == "": +# 실 검사 로직 +def check_shorten_domain(hostname: str | None) -> dict: + if not hostname: return { - "is_shortened_url": False, - "matched_shortener": "", + "detected": False, + "matched": "" } - # 단축 URL 도메인 목록과 비교 if hostname in SHORTENER_DOMAINS: - is_shortened_url = True - matched_shortener = hostname + return { + "detected": True, + "matched": hostname + } return { - "is_shortened_url": is_shortened_url, - "matched_shortener": matched_shortener, + "detected": False, + "matched": "" } +# 결과 조립 +def build_shorten_result(hostname: str | None) -> dict: + result = { + "detected": False, + "evidence": [], + "details": { + "matched_shortener": "" + } + } + + check = check_shorten_domain(hostname) + + if check["detected"]: + result["detected"] = True + result["evidence"].append(check["matched"]) + result["details"]["matched_shortener"] = check["matched"] + + return result + + # 단축 URL 서비스 도메인 목록 SHORTENER_DOMAINS = { "bit.ly", diff --git a/core/base.py b/core/base.py deleted file mode 100644 index 795ef73..0000000 --- a/core/base.py +++ /dev/null @@ -1,2 +0,0 @@ -# 전체 검사기의 기본 구조 및 호출 형식을 통일하는 기반입니다. -# 한 번 정의하면 수정 X diff --git a/core/context.py b/core/context.py index 0eb699b..6606141 100644 --- a/core/context.py +++ b/core/context.py @@ -1,2 +1,126 @@ # 검사의 내용을 실시간 기록하는 페이지입니다. # 공유 노트 -> 검사 시 필요한 데이터는 여기에 임시 저장, 호출하는 것을 규약으로 합니다. +# 검사 요청 수신 시, 반드시 create_initial_context(scan_id, original_url)를 호출해 context를 생성합니다. +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class AnalysisContext: + # 수신한 요청 정보 + request: Dict[str, Any] = field(default_factory=dict) + + # 정규화된 URL 정보 및 검사용 URL 원문 + url: Dict[str, Any] = field(default_factory=dict) + + # 리다이렉트 추적 결과 + redirect: Dict[str, Any] = field(default_factory=dict) + + # 룰 기반 검사기를 거친 결과 + detection: Dict[str, Any] = field(default_factory=dict) + + # ml 분석 결과 (현재 미구현) + + # flags : 판정 및 로직 분기 판별에서 사용하기 위한 플래그 + flags: Dict[str, Any] = field(default_factory=dict) + + # 스코어링 내용 및 결과 점수 + scoring: Dict[str, Any] = field(default_factory=dict) + + # 외부 시스템 연동 결과용 묶음 + # 지금은 실제 사용 보류 상태이므로 빈 구조만 유지 + external: Dict[str, Any] = field(default_factory=dict) + + # 내부 실행 상태, 경고, 에러, 완료 단계 등 저장 + meta: Dict[str, Any] = field(default_factory=dict) + + +# context 생성-> 분석 요청 수신 직후 반드시 호출 +def create_initial_context(request_id: str, original_url: str) -> AnalysisContext: + context = AnalysisContext( + request={ + "request_id": request_id, # 요청 식별용 UUID + "original_url": original_url, # 수신한 URL 원문 + }, + url={ + "original_url": original_url, # 원본 URL + "sanitized_url": None, # 공백 제거 등 1차 정리 후 URL + "normalized_url": None, # 정규화 완료된 URL + "scheme": None, # http 등 + "hostname": None, # 도메인 또는 host + "port": None, # 포트 번호 + "path": None, # 경로(path) + "query": None, # 쿼리 문자열 + "fragment": None, # fragment(# 이하) + "is_https": False, # https 사용 여부 + "has_explicit_scheme": False, # 원문에 scheme 존재 여부 + "is_default_port": False, # 기본 포트(80/443) 여부 + }, + redirect={ + "was_checked": False, # 리다이렉트 검사 진입 여부 + "redirect_count": 0, # 리다이렉트 횟수 + "final_url": None, # 최종 도착 URL + "redirect_chain": [], # 이동 경로 목록: dict 리스트 + "loop_detected": False, # 리다이렉트 루프 발생 여부 + "status_type": None, # 종료 시 상태 [status_code, status_label] + "result_type": None, # 정상 완료(true)/비정상 종료(false) 여부 + }, + detection={ + "shortened_url": { + "detected": False, + "evidence": [] + }, + "rule_based": { + "flags": [], + "suspicious_query_params": [], + "embedded_urls": [], + "suspicious_keywords": { + "path": [], + "query": [], + "fragment": [] + }, + } + }, + flags={ + "normalized": False, # 정규화 성공 여부 + "is_shortened_url": False, # 단축 URL 여부 + "redirect_checked": False, # 리다이렉트 검사 진행 여부 + "has_suspicious": False, # 룰 기반에서 이상 요소 여부 + "suspicious_details": [] # 이상 요소가 발견된 검사 + }, + + # scoring: 아직 확정 전이지만 결과 요약이 들어갈 자리 확보 + scoring={ + "score": None, # 최종 위험 점수 + "risk_level": None, # safe / caution / danger 등 + "reason_summary": [], # 판정 이유 요약 문장 목록 + "matched_rules": [], # 적용된 규칙 목록 + }, + + # external: 외부 API / 외부 시스템 연동 + external={ + "safe_browsing": { + "is_threat": False, # 위험(true)/요소 없음(false) 여부 + "status_type": None, # 종료 시 상태 [status_code, status_label] + "result_type": None, # 정상 완료(true)/비정상 종료(false) 여부 + "threat_labels": [] + }, + "report_count": None, + "block_count": None, + "domain_age": None + }, + # DB 조회 결과 + report_exist={ + "exists": False, + "data": None + }, + + # meta: 분석 진행 상태, 경고, 에러, 완료 단계 기록 + meta={ + "status": "initialized", # 현재 분석 상태 + "completed_steps": [], # 완료된 단계 목록 + "warnings": [], # 경고 메시지 목록 + "errors": [], # 에러 메시지 목록 + } + ) + return context \ No newline at end of file diff --git a/core/pipeline.py b/core/pipeline.py index 7dbd54a..8a5db4e 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -1,2 +1,97 @@ # 선행 검사 결과에 따라 변동되는 검사 과정 세트를 지정합니다. -# 예시: 단축 url 여부에 따라 갈리는 검사 진행 과정을 각각 정의 +from analyzers import ( + parameter_rule as rule, + redirect_tracker as track, + url_shorten_checker as short, + ai_analyze as ai, + req_api as api, + req_db as db, +) +from services import builder, scoring, url_utils as utils + + +def run_pipeline(context): + # 0. 단축 URL 검사 + short_result = short.url_shorten_checker(context) + context.detection["shortened_url"] = short_result + context.flags["is_shortened_url"] = short_result.get("detected", False) + + # 1. 분기 + if context.flags["is_shortened_url"]: + return run_shortened_pipeline(context) + else: + return run_normal_pipeline(context) + + +def run_shortened_pipeline(context): + # 1) 단축 URL은 먼저 리다이렉트 추적 + context.redirect = track.redirect_tracker(context) + + # 2) final_url 기준으로 정규화 + utils.normalize_url(context) + + # 3) DB 조회 + context.report_exist = db.req_db(context) + + if context.report_exist.get("exists"): + # DB에 있으면 그 결과 그대로 반환 후 종료 + return context.report_exist.get("data") + + # 4) 외부 API 조회 + context.external = api.req_api(context) + + safe_browsing = context.external.get("safe_browsing", {}) + if safe_browsing.get("is_threat"): + # 외부 API에서 위험 판정이면 즉시 종료 + result = builder.build_result(context) + return builder.build_JSON(result) + + # 5) 룰 기반 검사 + context.detection["rule_based"] = rule.parameter_rule(context) + + # 6) ML 검사 + context.detection["ml"] = ai.ai_analyze(context) + + # 7) 스코어링 + context.scoring = scoring.score_analysis(context) + + # 8) 최종 결과 반환 + result = builder.build_result(context) + return builder.build_JSON(result) + + +def run_normal_pipeline(context): + # 1) 일반 URL은 먼저 정규화 + utils.normalize_url(context) + + # 2) DB 조회 + context.report_exist = db.req_db(context) + + if context.report_exist.get("exists"): + # DB에 있으면 그 결과 그대로 반환 후 종료 + return context.report_exist.get("data") + + # 3) 외부 API 조회 + context.external = api.req_api(context) + + safe_browsing = context.external.get("safe_browsing", {}) + if safe_browsing.get("is_threat"): + # 외부 API에서 위험 판정이면 즉시 종료 + result = builder.build_result(context) + return builder.build_JSON(result) + + # 4) 룰 기반 검사 + context.detection["rule_based"] = rule.parameter_rule(context) + + # 5) ML 검사 + context.detection["ml"] = ai.ai_analyze(context) + + # 6) 일반 URL은 마지막에 리다이렉트 추적 + context.redirect = track.redirect_tracker(context) + + # 7) 스코어링 + context.scoring = scoring.score_analysis(context) + + # 8) 최종 결과 반환 + result = builder.build_result(context) + return builder.build_JSON(result) \ No newline at end of file diff --git a/main.py b/main.py index f7ea4f4..a063a96 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ # main interface for analysis +# TODO: 외부 조회 결과 등 정리. +내부 DB 조회 결과 존재할 경우에 대한 처리 + from fastapi import FastAPI from pydantic import BaseModel from datetime import datetime diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..daa1999 --- /dev/null +++ b/models/base.py @@ -0,0 +1,13 @@ +# 전체 검사기의 기본 구조 및 호출 형식을 통일하는 기반입니다. +# 입력: context, 결과: 각 검사 종료 후 context에 직접 기록(반환X) +from core.context import AnalysisContext + +def run_detector(detector, context: AnalysisContext): + try: + detector(context) + context.meta["completed_steps"].append(detector.__name__) + except Exception as e: + context.meta["errors"].append({ + "step": detector.__name__, + "error": str(e) + }) \ No newline at end of file diff --git a/models/response.py b/models/response.py deleted file mode 100644 index 0dcd5de..0000000 --- a/models/response.py +++ /dev/null @@ -1,106 +0,0 @@ -# 최종 응답 데이터. 즉, JSON 작성을 위한 데이터 구조 -# DB와 FE에 보내는 데이터의 내용이 다른 경우, 파이프라인 쪽에서 조립하고 해당 파일을 지우는 편이 나을 수 있을지도... -# 기능 개발 후 검토 -from dataclasses import dataclass, field, asdict -from typing import List, Optional -import json - - -# ========================= -# 1. 기능별 하위 DTO -# ========================= - -@dataclass -class HttpsResult: - """HTTPS 사용 여부""" - isSecure: bool - - -@dataclass -class ShortUrlResult: - """단축 URL 여부""" - isShortened: bool - - -@dataclass -class MlResult: - """ML 검사 결과""" - threats: List[str] = field(default_factory=list) - score: int = 0 - - -@dataclass -class ExternalApiResult: - """외부 API 조회 결과""" - checked: bool - provider: str - result: Optional[str] - - -@dataclass -class InternalDbResult: - """ - 내부 DB 조회 결과 - - exists: DB 내 URL 존재 여부 - - reportCount: 최근 신고 건수 - - blockCount: 최근 차단 건수 - """ - exists: bool - reportCount: Optional[int] = None - blockCount: Optional[int] = None - - -@dataclass -class RedirectResult: - """리다이렉션 추적 결과""" - finalUrl: str - redirectCount: int - - -@dataclass -class CertificateInfo: - """인증서 정보""" - valid: Optional[bool] = None - issuer: Optional[str] = None - validFrom: Optional[str] = None - validTo: Optional[str] = None - - -@dataclass -class ServerInfo: - """서버 유형 및 위치, 인증서 정보""" - type: str - location: str - certificate: Optional[CertificateInfo] = None - - -# ========================= -# 2. 최종 출력 DTO -# ========================= - -@dataclass -class AnalysisResponse: - """ - 최종 출력 JSON 구조 - """ - analysisTime: str - targetUrl: str - - https: HttpsResult - shortUrl: ShortUrlResult - ml: MlResult - externalApi: ExternalApiResult - internalDb: InternalDbResult - redirect: RedirectResult - serverInfo: ServerInfo - - score: int - riskLevel: str # safe / caution / threat - - def to_dict(self) -> dict: - """dataclass -> dict 변환""" - return asdict(self) - - def to_json(self) -> str: - """dataclass -> JSON 문자열 변환""" - return json.dumps(self.to_dict(), ensure_ascii=False, indent=2) diff --git a/models/result.py b/models/result.py index 1fac512..e0b7f12 100644 --- a/models/result.py +++ b/models/result.py @@ -1,46 +1,44 @@ -# 결과 json형식 -# 차후 개발 단계에 따라 실제 반환값 삽입 - -{ - "analysisTime": "yyyy-mm-ddT18:30:00+09:00", - "targetUrl": "http://example.com", - "https": { - "isSecure": true/false - }, - "shortUrl": { - "isShortened": true/false - }, - "ml": { - "threats": [ - "위협 요소", - "배열 형식" - ], - "score": 65 #0~100 - }, - "externalApi": { - "checked": true/false, - "provider": "Google Safe Browsing", - "result": "safe" #실패/결과 없을 시 null - }, - "internalDb": { - "exists": true/false, - "reportCount": 3, - "blockCount": 1 #count 둘 모두 없으면 null - }, - "redirect": { - "finalUrl": "https://example.com/login", - "redirectCount": 2 #정수 - }, - "serverInfo": { - "type": "nginx", #서버 유형 - "location": "US", #서버 위치 - "certificate": { #인증서 정보 - "valid": true/false, - "issuer": "인증서 발급자", - "validFrom": "2026-01-01T00:00:00Z", - "validTo": "2026-04-01T00:00:00Z" - } - }, - "score": 72, #최종 점수 - "riskLevel": "safe/caution/threat" -} \ No newline at end of file +# 최종 응답 데이터. 즉, JSON 작성을 위한 데이터 구조 +import datetime +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class AnalysisResult: + # --- 요청/식별 --- + request_id: str # 요청 UUID (현재 입력값 그대로 사용) + + # --- URL 정보 --- + original_url: str # 최초 입력 URL + final_url: Optional[str] = None # 최종 도착 URL + redirect_count: int = 0 # 리다이렉트 횟수 + + # --- URL 유형 --- + scheme_type: Optional[str] = "ANALYZED_WEB" + type_info: Dict[str, Any] = field(default_factory=dict) # URL 상세 정보? + + # --- 시간 --- + scanned_at: Optional[str] = None # 요청 수신 시간 + analyzed_at: Optional[str] = datetime.utcnow().isoformat() # 분석 완료 시간 + + # --- 보안 기본 정보 --- + is_https: Optional[bool] = None # HTTPS 사용 여부 + + # --- 위험 분석 --- + risk_score: Optional[float] = None # 위험 점수 + risk_level: Optional[str] = None # SAFE / CAUTION / THREAT + threat_label: List[str] = field(default_factory=list) # 탐지된 위협 요소 목록 + + # --- 서버/인증서 --- + server_type: Optional[str] = None + server_location: Optional[str] = None + + cert_valid_yn: Optional[bool] = None + cert_issuer: Optional[str] = None + cert_expiry: Optional[str] = None + + # --- 외부/통계 --- + report_count: Optional[int] = None + block_count: Optional[int] = None + domain_age: Optional[str] = None diff --git a/services/builder.py b/services/builder.py new file mode 100644 index 0000000..1d8be24 --- /dev/null +++ b/services/builder.py @@ -0,0 +1,103 @@ +# 검사 결과를 정리하고, 전송할 수 있는 형태로 build합니다. +from core.context import AnalysisContext +from models.result import AnalysisResult +from typing import List, Dict, Any + +# 각 검사별 결과 라벨 합치기 +def build_threat_label(context:AnalysisContext) -> List[Dict[str, Any]]: + threat_labels = [] + + for key, value in context.detection.items(): + if not value: + continue + + if value.get("detected"): + # evidence 없으면 빈 리스트 처리 + details = value.get("evidence", []) + + threat_labels.append({ + "type": key, + "details": details + }) + + # external (Safe Browsing 등) + safe_result = context.external.get("safe_browsing") if context.external else None + + if safe_result: + threat_labels.append({ + "type": "external_safe_browsing", + "details": [safe_result] if isinstance(safe_result, str) else safe_result + }) + + return threat_labels + + +# result에 데이터 채우기 +def build_result(context: AnalysisContext) -> AnalysisResult: + return AnalysisResult ( + request_id=context.request["request_id"], + + original_url=context.url["original_url"], + final_url=context.redirect["final_url"], + redirect_count=context.redirect["redirect_count"], + + scheme_type="ANALYZED_WEB", + + type_info={ + "scheme": context.url["scheme"], + "hostname": context.url["hostname"], + "port": context.url["port"], + }, + + scanned_at=context.meta.get("scanned_at"), + analyzed_at=context.meta.get("analyzed_at"), + + is_https=context.url["is_https"], + + risk_score=context.scoring["score"], + risk_level=context.scoring["risk_level"], + + threat_label=build_threat_label(context), + + server_type=None, + server_location=None, + cert_valid_yn=None, + cert_issuer=None, + cert_expiry=None, + report_count=None, + block_count=None, + domain_age=None, + ) + + +def build_JSON(result) -> dict: + return { + "analysisTime": result.analyzed_at, + "targetUrl": result.original_url, + + "https": { + "isSecure": result.is_https + }, + + "ml": { + "threats": result.threat_label + }, + + "redirect": { + "finalUrl": result.final_url, + "redirectCount": result.redirect_count + }, + + "serverInfo": { + "type": result.server_type, + "location": result.server_location, + "certificate": { + "valid": result.cert_valid_yn, + "issuer": result.cert_issuer, + "validTo": result.cert_expiry + } + }, + + "score": result.risk_score, + "riskLevel": result.risk_level + } \ No newline at end of file diff --git a/services/json_builder.py b/services/json_builder.py deleted file mode 100644 index 16efd60..0000000 --- a/services/json_builder.py +++ /dev/null @@ -1,2 +0,0 @@ -# 전송할 JSON 파일 제작용 -# sender이 별도 필요할까? 지금은 상관없을거같은데... diff --git a/services/response.py b/services/response.py new file mode 100644 index 0000000..c9282fd --- /dev/null +++ b/services/response.py @@ -0,0 +1 @@ +# BE3으로 전송하는 부분 diff --git a/services/scoring.py b/services/scoring.py new file mode 100644 index 0000000..ab8e5ad --- /dev/null +++ b/services/scoring.py @@ -0,0 +1,197 @@ +# 검사 결과에 기반해 점수를 매기는 로직입니다. +from core.context import AnalysisContext + + +def score_analysis(context: AnalysisContext) -> dict: + internal_score, internal_reasons = score_internal(context) + ml_score = score_ml(context) + external_score, external_reasons = score_external(context) + + final_score = round( + internal_score * 0.45 + + ml_score * 0.35 + + external_score * 0.20 + ) + + # Safe Browsing 위험이면 강제 threat + safe_browsing = context.external.get("safe_browsing", {}) + if safe_browsing.get("is_threat"): + final_score = max(final_score, 85) + + final_score = max(0, min(100, final_score)) + risk_level = decide_risk_level(final_score, safe_browsing) + + return build_scoring_result( + internal_score=internal_score, + ml_score=ml_score, + external_score=external_score, + score=final_score, + risk_level=risk_level, + reason_summary=internal_reasons + external_reasons, + matched_rules=collect_matched_rules(context) + ) + + +def score_internal(context: AnalysisContext) -> tuple[int, list]: + score = 0 + reasons = [] + + shortened = context.detection.get("shortened_url", {}) + if shortened.get("detected"): + score += 15 + reasons.append("shortened_url") + + redirect = context.redirect + redirect_count = redirect.get("redirect_count", 0) + + if redirect_count == 1: + score += 5 + reasons.append("redirect_1") + elif redirect_count == 2: + score += 10 + reasons.append("redirect_2") + elif redirect_count >= 3: + score += 15 + reasons.append("redirect_3_or_more") + + if redirect.get("loop_detected"): + score += 30 + reasons.append("redirect_loop_detected") + + status_type = redirect.get("status_type") + if status_type: + _, label = status_type + if label == "too_many_redirects": + score += 25 + reasons.append("too_many_redirects") + + rule_based = context.detection.get("rule_based", {}) + flags = rule_based.get("flags", []) + + flag_scores = { + "suspicious_query_param_detected": 12, + "embedded_url_in_query": 15, + "suspicious_query_keyword_detected": 8, + "suspicious_path_keyword_detected": 8, + "suspicious_fragment_keyword_detected": 8, + "percent_encoding_detected": 8, + "double_encoding_suspected": 15, + } + + for flag in flags: + score += flag_scores.get(flag, 0) + reasons.append(flag) + + return min(score, 100), reasons + + +def score_ml(context: AnalysisContext) -> int: + ml_result = context.detection.get("ml", {}) + score = ml_result.get("score", 0) + + try: + score = int(round(score)) + except Exception: + score = 0 + + return max(0, min(100, score)) + + +def score_external(context: AnalysisContext) -> tuple[int, list]: + score = 0 + reasons = [] + + external = context.external + + report_count = external.get("report_count") + if isinstance(report_count, int): + if 1 <= report_count <= 2: + score += 10 + reasons.append("report_count_low") + elif 3 <= report_count <= 5: + score += 20 + reasons.append("report_count_mid") + elif report_count >= 6: + score += 30 + reasons.append("report_count_high") + + block_count = external.get("block_count") + if isinstance(block_count, int): + if 1 <= block_count <= 2: + score += 10 + reasons.append("block_count_low") + elif block_count >= 3: + score += 20 + reasons.append("block_count_high") + + cert_valid = external.get("cert_valid_yn") + if cert_valid is False: + score += 25 + reasons.append("invalid_certificate") + + domain_age = external.get("domain_age") + if domain_age in ("new", "very_new"): + score += 15 + reasons.append("young_domain") + + server_type = external.get("server_type") + if server_type in (None, "", "unknown"): + score += 10 + reasons.append("unknown_server_type") + + return min(score, 100), reasons + + +def decide_risk_level(final_score: int, safe_browsing: dict) -> str: + if safe_browsing.get("is_threat"): + return "threat" + + if final_score >= 60: + return "threat" + if final_score >= 30: + return "caution" + return "safe" + + +def collect_matched_rules(context: AnalysisContext) -> list: + matched = [] + + shortened = context.detection.get("shortened_url", {}) + if shortened.get("detected"): + matched.append("shortened_url") + + rule_based = context.detection.get("rule_based", {}) + matched.extend(rule_based.get("flags", [])) + + safe_browsing = context.external.get("safe_browsing", {}) + matched.extend(safe_browsing.get("threat_labels", [])) + + ml_result = context.detection.get("ml", {}) + matched.extend(ml_result.get("threats", [])) + + # 중복 제거 + unique = [] + for item in matched: + if item not in unique: + unique.append(item) + + return unique + + +def build_scoring_result(**overrides) -> dict: + result = { + "internal_score": 0, + "ml_score": 0, + "external_score": 0, + "score": 0, + "risk_level": None, + "reason_summary": [], + "matched_rules": [] + } + + for key in overrides: + if key not in result: + raise KeyError(f"허용되지 않은 scoring 필드: {key}") + + result.update(overrides) + return result \ No newline at end of file diff --git a/services/url_utils.py b/services/url_utils.py index 1a91c62..e41532d 100644 --- a/services/url_utils.py +++ b/services/url_utils.py @@ -1,13 +1,10 @@ # URL을 분해하고, 정규화하고, 필요하다면 관련 다른 기능들도 제공합니다. from urllib.parse import urlsplit, urlunsplit +from core.context import AnalysisContext #입력 URL의 앞뒤 공백/탭/개행 제거 -#pipeline 조립 시 수신 url에 대해 반드시 첫 번째로 사용 def sanitize_input_url(raw_url: str) -> str: - - cleaned_url = "" # 공백 제거 후 사용할 URL 문자열 - if raw_url is None: raise ValueError("URL 입력값이 None입니다.") @@ -46,7 +43,6 @@ def parse_url_components(url: str) -> dict: #없을 시 https 부착 **검사 진행 위한 구조 정상화 목적** #소문자화 scheme, scheme 존재 여부, https 여부를 반환합니다. def normalize_scheme(parsed_result:dict): - scheme = parsed_result["scheme"] # scheme scheme = scheme.lower() # 소문자로 저장 scheme_exists = bool(scheme) # 보정 전 scheme 존재 여부 @@ -59,13 +55,12 @@ def normalize_scheme(parsed_result:dict): return { "normalized_scheme": scheme, "had_scheme_originally": scheme_exists, # 원본 scheme 존재 여부 - "is_https": is_https # 원본 https 여부 + "is_https": is_https # 원본 url이 https였는지 여부 } #hostname 소문자화, 기본 포트 여부 판단 def normalize_url_components(parsed_result: dict, normalized_scheme: str): - hostname = parsed_result["hostname"] # hostname port = parsed_result["port"] # port is_default_port = False # 기본 포트 여부(http:80/https:443) @@ -92,11 +87,9 @@ def normalize_url_components(parsed_result: dict, normalized_scheme: str): #URL 재구성 def rebuild_normalized_url(parsed_result: dict, normalized_scheme: str, normalized_comp: dict): - # hostname+port if normalized_comp["normalized_port"] is None: netloc = normalized_comp["normalized_hostname"] - else: netloc = f"{normalized_comp['normalized_hostname']}:{normalized_comp['normalized_port']}" @@ -114,54 +107,39 @@ def rebuild_normalized_url(parsed_result: dict, normalized_scheme: str, normaliz } -# 정규화된 URL이 분석 가능한 형태인지 검증합니다.. .d별로 의미 없어보이는데. -# 굳이해야되나? 안전상 확실하게 하려면 하는게 좋기는 하지만... 위에서 검사 한번씩 다 거쳤을텐데 -def validate_normalized_url(parsed_result: dict, scheme_result: dict, normalized_comp: dict): - - normalized_scheme = scheme_result["normalized_scheme"] # 정규화된 scheme - normalized_hostname = normalized_comp["normalized_hostname"] # 정규화된 hostname - - is_valid = True # 전체 유효성 여부 - error_reason = "" # 유효하지 않은 경우 사유 - - # hostname 존재 여부 확인 - if not normalized_hostname: - is_valid = False - error_reason = "hostname이 존재하지 않습니다." - - # scheme 허용 여부 확인 - elif normalized_scheme not in ("http", "https"): - is_valid = False - error_reason = "허용되지 않은 scheme입니다." - - return { - "is_valid": is_valid, - "error_reason": error_reason - } - -# 결과 통합 함수. URL 분석 결과를 불러올 때 사용합니다. -# 타 검사에서 활용할 데이터는 해당 dict에서 추출 -def build_url_utils_result( - raw_url: str, - cleaned_url: str, - parsed_result: dict, - scheme_result: dict, - normalized_comp: dict, - rebuilt_result: dict, - validation_result: dict -): - - return { - "raw_url": raw_url, # 원본 URL - "cleaned_url": cleaned_url, # 공백 제거 후 URL - - "parsed": parsed_result, # 분해 결과 - "scheme": scheme_result, # scheme 정규화 결과 (HTTPS 여부는 해당 부분에 포함) - "components": normalized_comp, # hostname/port 정규화 결과 - "rebuilt": rebuilt_result, # 재구성 결과 - "validation": validation_result, # 유효성 검증 결과 - - "normalized_url": rebuilt_result["normalized_url"], # 최종 정규화 URL - "is_valid": validation_result["is_valid"], # 최종 유효 여부 - "error_reason": validation_result["error_reason"] # 오류 사유 - } \ No newline at end of file +# context.url에 정규화 결과를 기록하는 메인 함수 +def normalize_url(context: AnalysisContext) -> None: + # 단축 URL이면 리다이렉트 후 최종 URL을 사용, 아니면 원본 URL 사용 + if context.flags.get("is_shortened_url"): + target_url = context.redirect.get("final_url") + else: + target_url = context.request.get("original_url") + + cleaned_url = sanitize_input_url(target_url) + parsed_result = parse_url_components(cleaned_url) + scheme_result = normalize_scheme(parsed_result) + normalized_comp = normalize_url_components( + parsed_result, + scheme_result["normalized_scheme"] + ) + rebuilt_result = rebuild_normalized_url( + parsed_result, + scheme_result["normalized_scheme"], + normalized_comp + ) + + # context.url에 최종 결과 기록 + context.url["sanitized_url"] = cleaned_url + context.url["normalized_url"] = rebuilt_result["normalized_url"] + context.url["scheme"] = scheme_result["normalized_scheme"] + context.url["hostname"] = normalized_comp["normalized_hostname"] + context.url["port"] = normalized_comp["normalized_port"] + context.url["path"] = parsed_result["path"] + context.url["query"] = parsed_result["query"] + context.url["fragment"] = parsed_result["fragment"] + context.url["is_https"] = scheme_result["is_https"] + context.url["has_explicit_scheme"] = scheme_result["had_scheme_originally"] + context.url["is_default_port"] = normalized_comp["is_default_port"] + + # 정규화 수행 완료 표시 + context.flags["normalized"] = True \ No newline at end of file From e5288b0f67b9096c60039cbe9667d23a25cdf26e Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 07:21:07 +0900 Subject: [PATCH 12/77] =?UTF-8?q?*refector*:=202=EC=B0=A8=20=EC=98=A4?= =?UTF-8?q?=ED=83=80=20=EB=B0=8F=20=EA=B5=AC=EC=A1=B0=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95,=20main=EC=97=90=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_api.py | 2 +- analyzers/req_db.py | 27 +++++----- analyzers/url_shorten_checker.py | 2 +- core/context.py | 7 ++- main.py | 63 ++++------------------- requirements.txt | 1 + services/builder.py | 85 +++++++++++++++----------------- 7 files changed, 69 insertions(+), 118 deletions(-) diff --git a/analyzers/req_api.py b/analyzers/req_api.py index fb62eca..982af22 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -24,7 +24,7 @@ # 메인 함수 # pipeline에서 넘겨준 target_url을 기준으로 Safe Browsing 조회 수행 def req_api(context:AnalysisContext) -> dict: - target_url=context.url.get("noramlized_url") + target_url=context.url.get("normalized_url") raw = GSB_req_api(target_url) parsed = parse_GSB_result(raw) diff --git a/analyzers/req_db.py b/analyzers/req_db.py index 36e30a2..b27ec20 100644 --- a/analyzers/req_db.py +++ b/analyzers/req_db.py @@ -1,11 +1,12 @@ -# 내부 DB 조회 +import os import pymysql from core.context import AnalysisContext -# 메인 함수 +db_password = os.getenv("MYSQL_ROOT_PASSWORD") +# ml_key = os.getenv("ML_SECRET_KEY") + def req_db(context: AnalysisContext) -> dict: url = context.url.get("normalized_url") - row = select_url_record(url) if row: @@ -20,37 +21,35 @@ def req_db(context: AnalysisContext) -> dict: ) -# 실제 DB 조회 def select_url_record(url: str | None): if not url: return None - conn = pymysql.connect( # env 보고 키 적용해서 변경 - host="DB_HOST", - user="DB_USER", - password="DB_PASSWORD", - database="DB_NAME", - charset="utf8" + conn = pymysql.connect( + host="veriq-db", + user="root", + password=db_password, + database="veriq_db", + port=3306, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, ) try: - with conn.cursor(pymysql.cursors.DictCursor) as cursor: + with conn.cursor() as cursor: cursor.execute( "SELECT * FROM url_analysis WHERE final_url = %s LIMIT 1", (url,) ) return cursor.fetchone() - finally: conn.close() -# 결과 조립 def build_db_result(**overrides) -> dict: result = { "exists": False, "data": None } - result.update(overrides) return result \ No newline at end of file diff --git a/analyzers/url_shorten_checker.py b/analyzers/url_shorten_checker.py index a37122f..69e974d 100644 --- a/analyzers/url_shorten_checker.py +++ b/analyzers/url_shorten_checker.py @@ -7,7 +7,7 @@ # hostname이 단축 URL 서비스 도메인인지 검사 def url_shorten_checker(context: AnalysisContext) -> dict: - hostname = context.url.get["hostname"] + hostname = context.url.get("hostname") return build_shorten_result(hostname) diff --git a/core/context.py b/core/context.py index 6606141..0559912 100644 --- a/core/context.py +++ b/core/context.py @@ -2,7 +2,7 @@ # 공유 노트 -> 검사 시 필요한 데이터는 여기에 임시 저장, 호출하는 것을 규약으로 합니다. # 검사 요청 수신 시, 반드시 create_initial_context(scan_id, original_url)를 호출해 context를 생성합니다. from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional +from typing import Any, Dict @dataclass @@ -37,7 +37,7 @@ class AnalysisContext: # context 생성-> 분석 요청 수신 직후 반드시 호출 def create_initial_context(request_id: str, original_url: str) -> AnalysisContext: - context = AnalysisContext( + return AnalysisContext( request={ "request_id": request_id, # 요청 식별용 UUID "original_url": original_url, # 수신한 URL 원문 @@ -122,5 +122,4 @@ def create_initial_context(request_id: str, original_url: str) -> AnalysisContex "warnings": [], # 경고 메시지 목록 "errors": [], # 에러 메시지 목록 } - ) - return context \ No newline at end of file + ) \ No newline at end of file diff --git a/main.py b/main.py index a063a96..e4ab272 100644 --- a/main.py +++ b/main.py @@ -1,66 +1,23 @@ # main interface for analysis -# TODO: 외부 조회 결과 등 정리. +내부 DB 조회 결과 존재할 경우에 대한 처리 - -from fastapi import FastAPI -from pydantic import BaseModel from datetime import datetime +from fastapi import FastAPI -from fastapi.responses import StreamingResponse -from services.sse import event_generator, send_step - -from analyzers.req_api import check_url_safe_browsing -from analyzers.req_db import check_url_exists -from analyzers.redirect_tracker import trace_redirect +from core.context import create_initial_context +from core.pipeline import run_pipeline +from models.receive import AnalysisRequest app = FastAPI() -# JSON 통신 여부 확인용 더미 응답 (DB에서 보내는 파일 형식에 맞춰서 수정 예정) -class AnalyzeRequest(BaseModel): - qr_url: str -# 기본 실행여부 @app.get("/") def root(): return {"message": "server is running"} -@app.post("/analyze") -def analyze(data: AnalyzeRequest): - "SSE작동 테스트용 호출" - send_step("start") - send_step("checking_input") - send_step("building_response") - send_step("done") - - return{ - "qr_url": data.qr_url, - "respond_test": "OK", - "analyzed_at": datetime.now().isoformat() - #"final_url": data.final_url, - #"risk_score": data.risk_score, - #"malicious_patterns": data.patterns - } - -# Safe Browsing 테스트 -@app.get("/test/safe-browsing") -def test_safe_browsing(url: str): - return check_url_safe_browsing(url) - -# DB 조회 테스트 -@app.get("/test/db") -def test_db(url: str): - return check_url_exists(url) -# SSE 엔드포인트 -@app.get("/stream") -def stream(): - return StreamingResponse(event_generator(), media_type="text/event-stream") - -# 리다이렉트 추적 테스트 -@app.post("/test/redirect") -def test_redirect_trace(url: str): - result = trace_redirect(url) +@app.post("/analyze") +def analyze(req: AnalysisRequest): + context = create_initial_context(req.scan_id, req.url) + context.meta["scanned_at"] = datetime.now().isoformat() - return { - "message": "redirect trace completed", - "redirect_result": result - } \ No newline at end of file + result = run_pipeline(context) + return result \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4d5133e..2573667 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ starlette==1.0.0 typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.44.0 +pymysql==1.1.2 diff --git a/services/builder.py b/services/builder.py index 1d8be24..da03c6a 100644 --- a/services/builder.py +++ b/services/builder.py @@ -4,32 +4,29 @@ from typing import List, Dict, Any # 각 검사별 결과 라벨 합치기 -def build_threat_label(context:AnalysisContext) -> List[Dict[str, Any]]: - threat_labels = [] +def build_threat_label(context: AnalysisContext) -> list[str]: + labels = [] - for key, value in context.detection.items(): - if not value: - continue + shortened = context.detection.get("shortened_url", {}) + if shortened.get("detected"): + labels.append("shortened_url") - if value.get("detected"): - # evidence 없으면 빈 리스트 처리 - details = value.get("evidence", []) + rule_based = context.detection.get("rule_based", {}) + for flag in rule_based.get("flags", []): + if flag not in labels: + labels.append(flag) - threat_labels.append({ - "type": key, - "details": details - }) + safe_browsing = context.external.get("safe_browsing", {}) + for label in safe_browsing.get("threat_labels", []): + if label not in labels: + labels.append(label) - # external (Safe Browsing 등) - safe_result = context.external.get("safe_browsing") if context.external else None + ml_result = context.detection.get("ml", {}) + for threat in ml_result.get("threats", []): + if threat not in labels: + labels.append(threat) - if safe_result: - threat_labels.append({ - "type": "external_safe_browsing", - "details": [safe_result] if isinstance(safe_result, str) else safe_result - }) - - return threat_labels + return labels # result에 데이터 채우기 @@ -38,56 +35,51 @@ def build_result(context: AnalysisContext) -> AnalysisResult: request_id=context.request["request_id"], original_url=context.url["original_url"], - final_url=context.redirect["final_url"], - redirect_count=context.redirect["redirect_count"], + final_url=context.redirect.get("final_url"), + redirect_count=context.redirect.get("redirect_count", 0), scheme_type="ANALYZED_WEB", - type_info={ - "scheme": context.url["scheme"], - "hostname": context.url["hostname"], - "port": context.url["port"], + "scheme": context.url.get("scheme"), + "hostname": context.url.get("hostname"), + "port": context.url.get("port"), }, scanned_at=context.meta.get("scanned_at"), analyzed_at=context.meta.get("analyzed_at"), - is_https=context.url["is_https"], - - risk_score=context.scoring["score"], - risk_level=context.scoring["risk_level"], + is_https=context.url.get("is_https"), + risk_score=context.scoring.get("score"), + risk_level=context.scoring.get("risk_level"), threat_label=build_threat_label(context), - server_type=None, - server_location=None, - cert_valid_yn=None, - cert_issuer=None, - cert_expiry=None, - report_count=None, - block_count=None, - domain_age=None, + server_type=context.external.get("server_type"), + server_location=context.external.get("server_location"), + cert_valid_yn=context.external.get("cert_valid_yn"), + cert_issuer=context.external.get("cert_issuer"), + cert_expiry=context.external.get("cert_expiry"), + + report_count=context.external.get("report_count"), + block_count=context.external.get("block_count"), + domain_age=context.external.get("domain_age"), ) -def build_JSON(result) -> dict: +def build_JSON(result: AnalysisResult) -> dict: return { "analysisTime": result.analyzed_at, "targetUrl": result.original_url, - "https": { "isSecure": result.is_https }, - "ml": { "threats": result.threat_label }, - "redirect": { "finalUrl": result.final_url, "redirectCount": result.redirect_count }, - "serverInfo": { "type": result.server_type, "location": result.server_location, @@ -97,7 +89,10 @@ def build_JSON(result) -> dict: "validTo": result.cert_expiry } }, - + "internalDb": { + "reportCount": result.report_count, + "blockCount": result.block_count + }, "score": result.risk_score, "riskLevel": result.risk_level } \ No newline at end of file From 3e5732de423d8c0bc052a25e7456bb6fbbeeced4 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 08:54:14 +0900 Subject: [PATCH 13/77] =?UTF-8?q?*feat/refactor*:=20=ED=86=B5=EC=8B=A0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84/=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/ai_analyze.py | 32 +++++------ analyzers/redirect_tracker.py | 2 +- analyzers/req_api.py | 43 +++++++-------- analyzers/req_db.py | 30 +++++++++-- core/context.py | 91 +++++++++++++++----------------- core/pipeline.py | 99 ++++++++++++++++++++++++----------- main.py | 20 ++++++- models/receive.py | 11 ++-- requirements.txt | 1 + services/response.py | 77 +++++++++++++++++++++++++++ services/sse.py | 30 ----------- 11 files changed, 279 insertions(+), 157 deletions(-) delete mode 100644 services/sse.py diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py index acee385..1a87090 100644 --- a/analyzers/ai_analyze.py +++ b/analyzers/ai_analyze.py @@ -4,12 +4,22 @@ from core.context import AnalysisContext -# 메인 함수 -# context.redirect.final_url을 입력으로 사용하여 ML 결과 dict를 반환 +# [수정] ML 입력 URL 결정 함수 추가 +def resolve_ml_target_url(context: AnalysisContext) -> str | None: + if context.flags.get("is_shortened_url"): + return context.redirect.get("final_url") or context.url.get("normalized_url") + + if context.redirect.get("was_checked") and context.redirect.get("final_url"): + return context.redirect.get("final_url") + + return context.url.get("normalized_url") or context.request.get("original_url") + + def ai_analyze(context: AnalysisContext) -> dict: - final_url = context.redirect.get("final_url") + # [수정] final_url 고정 사용 제거 + target_url = resolve_ml_target_url(context) - result = run_ml_inference(final_url) + result = run_ml_inference(target_url) return build_ml_result( score=result["score"], @@ -17,29 +27,19 @@ def ai_analyze(context: AnalysisContext) -> dict: ) -# 학습된 모델 호출 자리 -# 현재는 더미 결과를 반환 -def run_ml_inference(final_url: str | None) -> dict: - if not final_url: +def run_ml_inference(target_url: str | None) -> dict: + if not target_url: return { "score": 0.0, "threats": [] } - # TODO: - # - FEB feature extraction - # - XGB 모델 로드 - # - predict / predict_proba 수행 - # - 위협 요소 추출 - - # 현재는 더미 반환 return { "score": 0.61, "threats": ["dummy_ml_threat"] } -# ML 결과 dict 조립 def build_ml_result(score: float = 0.0, threats: list | None = None) -> dict: if threats is None: threats = [] diff --git a/analyzers/redirect_tracker.py b/analyzers/redirect_tracker.py index 9645071..028770f 100644 --- a/analyzers/redirect_tracker.py +++ b/analyzers/redirect_tracker.py @@ -4,7 +4,7 @@ from core.context import AnalysisContext -REQUEST_TIMEOUT = 5 +REQUEST_TIMEOUT = 30 MAX_REDIRECT_HOPS = 30 STATUS_SUCCESS = "success" diff --git a/analyzers/req_api.py b/analyzers/req_api.py index 982af22..fc0ba28 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -8,7 +8,6 @@ load_dotenv() -# GSB = google safe browsing GSB_CODE = "GSB" GSB_API_KEY = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY") @@ -17,14 +16,11 @@ STATUS_REQUEST_FAILED = "request_failed" STATUS_INVALID_INPUT = "invalid_input" STATUS_CLIENT_ERROR = "client_error" -STATUS_REQUEST_FAILED = "request_failed" - +STATUS_MISSING_API_KEY = "missing_api_key" # [수정] -# 메인 함수 -# pipeline에서 넘겨준 target_url을 기준으로 Safe Browsing 조회 수행 -def req_api(context:AnalysisContext) -> dict: - target_url=context.url.get("normalized_url") +def req_api(context: AnalysisContext) -> dict: + target_url = context.url.get("normalized_url") raw = GSB_req_api(target_url) parsed = parse_GSB_result(raw) @@ -32,21 +28,33 @@ def req_api(context:AnalysisContext) -> dict: return build_api_result("safe_browsing", parsed) -# 요청 본문 def GSB_req_api(url: str | None) -> dict: + # [수정] 입력값 방어 + if not url: + return {"type": STATUS_INVALID_INPUT} + + # [수정] API 키 누락 방어 + if not GSB_API_KEY: + return {"type": STATUS_MISSING_API_KEY} + endpoint = f"https://safebrowsing.googleapis.com/v4/threatMatches:find?key={GSB_API_KEY}" body = GSB_req_build(url) start = time.time() try: - response = requests.post(endpoint, json=body, timeout=5) + response = requests.post(endpoint, json=body, timeout=30) elapsed = time.time() - start - return { # TODO: 정상적으로 처리되는지 확인 필요 + try: + response_data = response.json() if response.status_code == 200 else None + except ValueError: # [수정] JSON 파싱 실패 방어 + response_data = None + + return { "type": STATUS_SUCCESS, "status_code": response.status_code, - "data": response.json() if response.status_code == 200 else None, + "data": response_data, "elapsed": elapsed } @@ -55,12 +63,11 @@ def GSB_req_api(url: str | None) -> dict: except requests.exceptions.RequestException: return {"type": STATUS_REQUEST_FAILED} - + def parse_GSB_result(raw: dict) -> dict: rtype = raw.get("type") - # 실패 계열 if rtype != STATUS_SUCCESS: return { "is_threat": False, @@ -69,7 +76,6 @@ def parse_GSB_result(raw: dict) -> dict: "threat_labels": [] } - # HTTP 상태 체크 status_code = raw.get("status_code") if status_code != 200: return { @@ -79,7 +85,7 @@ def parse_GSB_result(raw: dict) -> dict: "threat_labels": [] } - data = raw.get("data", {}) + data = raw.get("data") or {} matches = data.get("matches", []) if not matches: @@ -90,9 +96,7 @@ def parse_GSB_result(raw: dict) -> dict: "threat_labels": [] } - # 위협 존재 threat_types = list(set(m["threatType"] for m in matches)) - labels = [f"{GSB_CODE}:{t}" for t in threat_types] return { @@ -102,7 +106,6 @@ def parse_GSB_result(raw: dict) -> dict: "threat_labels": labels } -# ================= 최종 결과 조립 ================= def build_api_result(name: str, parsed: dict) -> dict: base = { @@ -120,9 +123,7 @@ def build_api_result(name: str, parsed: dict) -> dict: base[name].update(parsed) return base -# ============= 요청 body 빌드 함수 =============== -# GSB 요청 body 생성 def GSB_req_build(url: str): return { "client": { @@ -142,4 +143,4 @@ def GSB_req_build(url: str): {"url": url} ] } - } + } \ No newline at end of file diff --git a/analyzers/req_db.py b/analyzers/req_db.py index b27ec20..6cedf59 100644 --- a/analyzers/req_db.py +++ b/analyzers/req_db.py @@ -3,21 +3,39 @@ from core.context import AnalysisContext db_password = os.getenv("MYSQL_ROOT_PASSWORD") -# ml_key = os.getenv("ML_SECRET_KEY") + def req_db(context: AnalysisContext) -> dict: url = context.url.get("normalized_url") - row = select_url_record(url) + + try: + row = select_url_record(url) + except pymysql.MySQLError as e: # [수정] + return build_db_result( + exists=False, + data=None, + status="db_error", + error=str(e) + ) + except Exception as e: # [수정] + return build_db_result( + exists=False, + data=None, + status="unknown_error", + error=str(e) + ) if row: return build_db_result( exists=True, - data=row + data=row, + status="success" # [수정] ) return build_db_result( exists=False, - data=None + data=None, + status="success" # [수정] ) @@ -49,7 +67,9 @@ def select_url_record(url: str | None): def build_db_result(**overrides) -> dict: result = { "exists": False, - "data": None + "data": None, + "status": None, # [수정] + "error": None # [수정] } result.update(overrides) return result \ No newline at end of file diff --git a/core/context.py b/core/context.py index 0559912..6d54151 100644 --- a/core/context.py +++ b/core/context.py @@ -19,8 +19,6 @@ class AnalysisContext: # 룰 기반 검사기를 거친 결과 detection: Dict[str, Any] = field(default_factory=dict) - # ml 분석 결과 (현재 미구현) - # flags : 판정 및 로직 분기 판별에서 사용하기 위한 플래그 flags: Dict[str, Any] = field(default_factory=dict) @@ -28,42 +26,44 @@ class AnalysisContext: scoring: Dict[str, Any] = field(default_factory=dict) # 외부 시스템 연동 결과용 묶음 - # 지금은 실제 사용 보류 상태이므로 빈 구조만 유지 external: Dict[str, Any] = field(default_factory=dict) + # [수정] DB 조회 결과 필드 추가 + report_exist: Dict[str, Any] = field(default_factory=dict) + # 내부 실행 상태, 경고, 에러, 완료 단계 등 저장 meta: Dict[str, Any] = field(default_factory=dict) -# context 생성-> 분석 요청 수신 직후 반드시 호출 -def create_initial_context(request_id: str, original_url: str) -> AnalysisContext: +def create_initial_context(guest_uuid: str, original_url: str) -> AnalysisContext: return AnalysisContext( request={ - "request_id": request_id, # 요청 식별용 UUID - "original_url": original_url, # 수신한 URL 원문 + "guest_uuid": guest_uuid, # [추가] + "request_id": guest_uuid, # [추가] 기존 builder 호환용 + "original_url": original_url, }, url={ - "original_url": original_url, # 원본 URL - "sanitized_url": None, # 공백 제거 등 1차 정리 후 URL - "normalized_url": None, # 정규화 완료된 URL - "scheme": None, # http 등 - "hostname": None, # 도메인 또는 host - "port": None, # 포트 번호 - "path": None, # 경로(path) - "query": None, # 쿼리 문자열 - "fragment": None, # fragment(# 이하) - "is_https": False, # https 사용 여부 - "has_explicit_scheme": False, # 원문에 scheme 존재 여부 - "is_default_port": False, # 기본 포트(80/443) 여부 + "original_url": original_url, + "sanitized_url": None, + "normalized_url": None, + "scheme": None, + "hostname": None, + "port": None, + "path": None, + "query": None, + "fragment": None, + "is_https": False, + "has_explicit_scheme": False, + "is_default_port": False, }, redirect={ - "was_checked": False, # 리다이렉트 검사 진입 여부 - "redirect_count": 0, # 리다이렉트 횟수 - "final_url": None, # 최종 도착 URL - "redirect_chain": [], # 이동 경로 목록: dict 리스트 - "loop_detected": False, # 리다이렉트 루프 발생 여부 - "status_type": None, # 종료 시 상태 [status_code, status_label] - "result_type": None, # 정상 완료(true)/비정상 종료(false) 여부 + "was_checked": False, + "redirect_count": 0, + "final_url": None, + "redirect_chain": [], + "loop_detected": False, + "status_type": None, + "result_type": None, }, detection={ "shortened_url": { @@ -82,44 +82,37 @@ def create_initial_context(request_id: str, original_url: str) -> AnalysisContex } }, flags={ - "normalized": False, # 정규화 성공 여부 - "is_shortened_url": False, # 단축 URL 여부 - "redirect_checked": False, # 리다이렉트 검사 진행 여부 - "has_suspicious": False, # 룰 기반에서 이상 요소 여부 - "suspicious_details": [] # 이상 요소가 발견된 검사 + "normalized": False, + "is_shortened_url": False, + "redirect_checked": False, + "has_suspicious": False, + "suspicious_details": [] }, - - # scoring: 아직 확정 전이지만 결과 요약이 들어갈 자리 확보 scoring={ - "score": None, # 최종 위험 점수 - "risk_level": None, # safe / caution / danger 등 - "reason_summary": [], # 판정 이유 요약 문장 목록 - "matched_rules": [], # 적용된 규칙 목록 + "score": None, + "risk_level": None, + "reason_summary": [], + "matched_rules": [], }, - - # external: 외부 API / 외부 시스템 연동 external={ "safe_browsing": { - "is_threat": False, # 위험(true)/요소 없음(false) 여부 - "status_type": None, # 종료 시 상태 [status_code, status_label] - "result_type": None, # 정상 완료(true)/비정상 종료(false) 여부 + "is_threat": False, + "status_type": None, + "result_type": None, "threat_labels": [] }, "report_count": None, "block_count": None, "domain_age": None }, - # DB 조회 결과 report_exist={ "exists": False, "data": None }, - - # meta: 분석 진행 상태, 경고, 에러, 완료 단계 기록 meta={ - "status": "initialized", # 현재 분석 상태 - "completed_steps": [], # 완료된 단계 목록 - "warnings": [], # 경고 메시지 목록 - "errors": [], # 에러 메시지 목록 + "status": "initialized", + "completed_steps": [], + "warnings": [], + "errors": [], } ) \ No newline at end of file diff --git a/core/pipeline.py b/core/pipeline.py index 8a5db4e..076e60f 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -1,4 +1,6 @@ # 선행 검사 결과에 따라 변동되는 검사 과정 세트를 지정합니다. +from datetime import datetime # [추가] + from analyzers import ( parameter_rule as rule, redirect_tracker as track, @@ -8,15 +10,48 @@ req_db as db, ) from services import builder, scoring, url_utils as utils +from services import response as callback # [추가] + + +# [추가] 공통 progress 함수 +def _send_progress(context, step: str, status: str, message: str): + guest_uuid = context.request.get("guest_uuid") + result = callback.send_progress(guest_uuid, step, status, message) + + if not result.get("ok"): + context.meta["warnings"].append({ + "type": "progress_callback_failed", + "step": step, + "status": status, + "error": result.get("error") + }) + + +# [추가] 공통 최종 JSON 생성 +def _build_final_json(context) -> dict: + context.meta["analyzed_at"] = datetime.now().isoformat() + result = builder.build_result(context) + return builder.build_JSON(result) def run_pipeline(context): - # 0. 단축 URL 검사 + # [수정] 단축 URL 검사 전에 hostname만 먼저 확보 + raw_url = context.request.get("original_url") + cleaned_url = utils.sanitize_input_url(raw_url) + parsed_result = utils.parse_url_components(cleaned_url) + + if not parsed_result.get("hostname"): + parsed_result = utils.parse_url_components(f"https://{cleaned_url}") + + context.url["sanitized_url"] = cleaned_url + context.url["hostname"] = (parsed_result.get("hostname") or "").lower() + + _send_progress(context, "SHORT_URL_CHECK", "IN_PROGRESS", "단축 URL 여부를 확인하고 있습니다.") short_result = short.url_shorten_checker(context) context.detection["shortened_url"] = short_result context.flags["is_shortened_url"] = short_result.get("detected", False) + _send_progress(context, "SHORT_URL_CHECK", "COMPLETED", "단축 URL 여부 확인이 완료되었습니다.") - # 1. 분기 if context.flags["is_shortened_url"]: return run_shortened_pipeline(context) else: @@ -24,74 +59,78 @@ def run_pipeline(context): def run_shortened_pipeline(context): - # 1) 단축 URL은 먼저 리다이렉트 추적 + _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 경로를 추적하고 있습니다.") context.redirect = track.redirect_tracker(context) + _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 추적이 완료되었습니다.") - # 2) final_url 기준으로 정규화 + _send_progress(context, "URL_NORMALIZE", "IN_PROGRESS", "최종 URL을 정규화하고 있습니다.") utils.normalize_url(context) + _send_progress(context, "URL_NORMALIZE", "COMPLETED", "최종 URL 정규화가 완료되었습니다.") - # 3) DB 조회 + _send_progress(context, "DB_CHECK", "IN_PROGRESS", "내부 DB에서 기존 분석 이력을 조회하고 있습니다.") context.report_exist = db.req_db(context) + _send_progress(context, "DB_CHECK", "COMPLETED", "내부 DB 조회가 완료되었습니다.") if context.report_exist.get("exists"): - # DB에 있으면 그 결과 그대로 반환 후 종료 return context.report_exist.get("data") - # 4) 외부 API 조회 + _send_progress(context, "SAFE_BROWSING", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") context.external = api.req_api(context) + _send_progress(context, "SAFE_BROWSING", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") safe_browsing = context.external.get("safe_browsing", {}) if safe_browsing.get("is_threat"): - # 외부 API에서 위험 판정이면 즉시 종료 - result = builder.build_result(context) - return builder.build_JSON(result) + return _build_final_json(context) - # 5) 룰 기반 검사 + _send_progress(context, "RULE_ANALYSIS", "IN_PROGRESS", "도메인 및 파라미터 규칙 분석을 진행하고 있습니다.") context.detection["rule_based"] = rule.parameter_rule(context) + _send_progress(context, "RULE_ANALYSIS", "COMPLETED", "도메인 및 파라미터 규칙 분석이 완료되었습니다.") - # 6) ML 검사 + _send_progress(context, "ML_ANALYSIS", "IN_PROGRESS", "ML 기반 위험 분석을 진행하고 있습니다.") context.detection["ml"] = ai.ai_analyze(context) + _send_progress(context, "ML_ANALYSIS", "COMPLETED", "ML 기반 위험 분석이 완료되었습니다.") - # 7) 스코어링 + _send_progress(context, "SCORING", "IN_PROGRESS", "최종 위험도를 계산하고 있습니다.") context.scoring = scoring.score_analysis(context) + _send_progress(context, "SCORING", "COMPLETED", "최종 위험도 계산이 완료되었습니다.") - # 8) 최종 결과 반환 - result = builder.build_result(context) - return builder.build_JSON(result) + return _build_final_json(context) def run_normal_pipeline(context): - # 1) 일반 URL은 먼저 정규화 + _send_progress(context, "URL_NORMALIZE", "IN_PROGRESS", "URL을 정규화하고 있습니다.") utils.normalize_url(context) + _send_progress(context, "URL_NORMALIZE", "COMPLETED", "URL 정규화가 완료되었습니다.") - # 2) DB 조회 + _send_progress(context, "DB_CHECK", "IN_PROGRESS", "내부 DB에서 기존 분석 이력을 조회하고 있습니다.") context.report_exist = db.req_db(context) + _send_progress(context, "DB_CHECK", "COMPLETED", "내부 DB 조회가 완료되었습니다.") if context.report_exist.get("exists"): - # DB에 있으면 그 결과 그대로 반환 후 종료 return context.report_exist.get("data") - # 3) 외부 API 조회 + _send_progress(context, "SAFE_BROWSING", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") context.external = api.req_api(context) + _send_progress(context, "SAFE_BROWSING", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") safe_browsing = context.external.get("safe_browsing", {}) if safe_browsing.get("is_threat"): - # 외부 API에서 위험 판정이면 즉시 종료 - result = builder.build_result(context) - return builder.build_JSON(result) + return _build_final_json(context) - # 4) 룰 기반 검사 + _send_progress(context, "RULE_ANALYSIS", "IN_PROGRESS", "도메인 및 파라미터 규칙 분석을 진행하고 있습니다.") context.detection["rule_based"] = rule.parameter_rule(context) + _send_progress(context, "RULE_ANALYSIS", "COMPLETED", "도메인 및 파라미터 규칙 분석이 완료되었습니다.") - # 5) ML 검사 + _send_progress(context, "ML_ANALYSIS", "IN_PROGRESS", "ML 기반 위험 분석을 진행하고 있습니다.") context.detection["ml"] = ai.ai_analyze(context) + _send_progress(context, "ML_ANALYSIS", "COMPLETED", "ML 기반 위험 분석이 완료되었습니다.") - # 6) 일반 URL은 마지막에 리다이렉트 추적 + _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 여부를 확인하고 있습니다.") context.redirect = track.redirect_tracker(context) + _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 확인이 완료되었습니다.") - # 7) 스코어링 + _send_progress(context, "SCORING", "IN_PROGRESS", "최종 위험도를 계산하고 있습니다.") context.scoring = scoring.score_analysis(context) + _send_progress(context, "SCORING", "COMPLETED", "최종 위험도 계산이 완료되었습니다.") - # 8) 최종 결과 반환 - result = builder.build_result(context) - return builder.build_JSON(result) \ No newline at end of file + return _build_final_json(context) \ No newline at end of file diff --git a/main.py b/main.py index e4ab272..f164560 100644 --- a/main.py +++ b/main.py @@ -5,6 +5,7 @@ from core.context import create_initial_context from core.pipeline import run_pipeline from models.receive import AnalysisRequest +from services import response as callback # [추가] app = FastAPI() @@ -16,8 +17,23 @@ def root(): @app.post("/analyze") def analyze(req: AnalysisRequest): - context = create_initial_context(req.scan_id, req.url) + # [수정] guest_uuid 기준 + context = create_initial_context(req.guest_uuid, req.url) context.meta["scanned_at"] = datetime.now().isoformat() result = run_pipeline(context) - return result \ No newline at end of file + + # [추가] 최종 결과 callback 전송 + callback_result = callback.send_final_result(result) + + if not callback_result.get("ok"): + context.meta["warnings"].append({ + "type": "final_callback_failed", + "error": callback_result.get("error") + }) + + # [추가] 로컬 확인용으로 callback 상태 포함 + return { + "analysisResult": result, + "callback": callback_result + } \ No newline at end of file diff --git a/models/receive.py b/models/receive.py index 51f24a9..7b71ce3 100644 --- a/models/receive.py +++ b/models/receive.py @@ -1,6 +1,11 @@ # url 분석 요청 시 수신하는 데이터의 형식을 정의합니다. -from pydantic import BaseModel +from pydantic import BaseModel, Field + class AnalysisRequest(BaseModel): - scan_id: str # 분석 요청 식별 ID - url: str # 분석 대상 URL 원본 \ No newline at end of file + # [수정] BE3에서 넘겨주는 식별자 기준으로 변경 + guest_uuid: str = Field(alias="guestUuid") + url: str + + class Config: + populate_by_name = True \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 2573667..a7179e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,3 +14,4 @@ typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.44.0 pymysql==1.1.2 +requests==2.32.3 \ No newline at end of file diff --git a/services/response.py b/services/response.py index c9282fd..22f3f01 100644 --- a/services/response.py +++ b/services/response.py @@ -1 +1,78 @@ # BE3으로 전송하는 부분 +import os +import requests + +# [추가] 환경변수 +ML_SECRET_KEY = os.getenv("ML_SECRET_KEY") +CALLBACK_PROGRESS_URL = os.getenv("BE3_CALLBACK_PROGRESS_URL") +CALLBACK_FINAL_URL = os.getenv("BE3_CALLBACK_FINAL_URL") + +TIMEOUT_SECONDS = 5 + + +def _build_headers() -> dict: + return { + "X-ML-Secret": ML_SECRET_KEY or "", + "Content-Type": "application/json", + } + + +def _post_json(url: str | None, payload: dict) -> dict: + # [추가] 통신 자체가 불가능한 경우도 결과로 반환 + if not url: + return { + "ok": False, + "status_code": None, + "error": "callback_url_missing" + } + + if not ML_SECRET_KEY: + return { + "ok": False, + "status_code": None, + "error": "ml_secret_missing" + } + + try: + response = requests.post( + url, + json=payload, + headers=_build_headers(), + timeout=TIMEOUT_SECONDS + ) + + return { + "ok": response.ok, + "status_code": response.status_code, + "error": None if response.ok else response.text + } + + except requests.exceptions.Timeout: + return { + "ok": False, + "status_code": None, + "error": "timeout" + } + + except requests.exceptions.RequestException as e: + return { + "ok": False, + "status_code": None, + "error": str(e) + } + + +# [추가] 중간보고 +def send_progress(guest_uuid: str, step: str, status: str, message: str) -> dict: + payload = { + "guestUuid": guest_uuid, + "step": step, + "status": status, + "message": message + } + return _post_json(CALLBACK_PROGRESS_URL, payload) + + +# [추가] 최종보고 +def send_final_result(result_json: dict) -> dict: + return _post_json(CALLBACK_FINAL_URL, result_json) \ No newline at end of file diff --git a/services/sse.py b/services/sse.py deleted file mode 100644 index 687563b..0000000 --- a/services/sse.py +++ /dev/null @@ -1,30 +0,0 @@ -#기능서버->프론트 서버 이벤트 모듈 -#진행 단계 알림이 -""" -현재 시스템상 모든 사용자가 같은 큐를 공유함 -=여러 사용자 동시 진행 불가능 -추후 request_id별 분리 -""" -from queue import Queue -import time - -# 전역 큐 (이벤트 통로) -event_queue = Queue() - - -# 외부에서 호출할 함수 (핵심) -def send_step(step: str): - event_queue.put(step) - - -# SSE용 generator -def event_generator(): - # 처음 연결되면 인사 한 번 - yield "data: hello\n\n" - - while True: - if not event_queue.empty(): - step = event_queue.get() - yield f"data: {step}\n\n" - - time.sleep(0.1) \ No newline at end of file From 8863c0ec9b05d151c8f36707f70530ea8b59e1f8 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 09:19:45 +0900 Subject: [PATCH 14/77] =?UTF-8?q?**=EC=8B=9C=EA=B0=84=20=ED=91=9C=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/result.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/models/result.py b/models/result.py index e0b7f12..36d5f5e 100644 --- a/models/result.py +++ b/models/result.py @@ -1,5 +1,5 @@ # 최종 응답 데이터. 즉, JSON 작성을 위한 데이터 구조 -import datetime +import datetime, timezone from dataclasses import dataclass, field from typing import Any, Dict, List, Optional @@ -20,7 +20,7 @@ class AnalysisResult: # --- 시간 --- scanned_at: Optional[str] = None # 요청 수신 시간 - analyzed_at: Optional[str] = datetime.utcnow().isoformat() # 분석 완료 시간 + analyzed_at: Optional[str] = datetime.now(timezone.utc).isoformat() # 분석 완료 시간 # --- 보안 기본 정보 --- is_https: Optional[bool] = None # HTTPS 사용 여부 From 77639cbe30ebfd9371bb3a31e7096667c077128c Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 09:35:01 +0900 Subject: [PATCH 15/77] * --- models/result.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/result.py b/models/result.py index 36d5f5e..c140899 100644 --- a/models/result.py +++ b/models/result.py @@ -1,5 +1,5 @@ # 최종 응답 데이터. 즉, JSON 작성을 위한 데이터 구조 -import datetime, timezone +from datetime import datetime, timezone from dataclasses import dataclass, field from typing import Any, Dict, List, Optional From c45e7dd2c0334e479c6dc621db59a3a82082ef07 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 11:41:55 +0900 Subject: [PATCH 16/77] * --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index f164560..e3c731c 100644 --- a/main.py +++ b/main.py @@ -18,7 +18,7 @@ def root(): @app.post("/analyze") def analyze(req: AnalysisRequest): # [수정] guest_uuid 기준 - context = create_initial_context(req.guest_uuid, req.url) + context = create_initial_context(req.guest_Uuid, req.url) context.meta["scanned_at"] = datetime.now().isoformat() result = run_pipeline(context) From 90f02c4bbe9a61bc176fec96ffd43bd8e2c172c3 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 11:47:27 +0900 Subject: [PATCH 17/77] =?UTF-8?q?*=EB=8C=80=EC=86=8C=EB=AC=B8=EC=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/receive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/models/receive.py b/models/receive.py index 7b71ce3..3d70f7e 100644 --- a/models/receive.py +++ b/models/receive.py @@ -4,7 +4,7 @@ class AnalysisRequest(BaseModel): # [수정] BE3에서 넘겨주는 식별자 기준으로 변경 - guest_uuid: str = Field(alias="guestUuid") + guest_Uuid: str = Field(alias="guestUuid") url: str class Config: From 811d77f4d64ebed8ea7823b311bfd026e81c61bb Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 11:57:00 +0900 Subject: [PATCH 18/77] =?UTF-8?q?*callback=20=EC=B6=94=EA=B0=80+=EB=8C=80?= =?UTF-8?q?=EC=86=8C=EB=AC=B8=EC=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/pipeline.py | 6 ++++-- main.py | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/core/pipeline.py b/core/pipeline.py index 076e60f..c6c7612 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -15,8 +15,8 @@ # [추가] 공통 progress 함수 def _send_progress(context, step: str, status: str, message: str): - guest_uuid = context.request.get("guest_uuid") - result = callback.send_progress(guest_uuid, step, status, message) + guest_Uuid = context.request.get("guest_Uuid") + result = callback.send_progress(guest_Uuid, step, status, message) if not result.get("ok"): context.meta["warnings"].append({ @@ -35,6 +35,8 @@ def _build_final_json(context) -> dict: def run_pipeline(context): + _send_progress(context, "TEST", "IN_PROGRESS", "파이프라인 진입 성공") + # [수정] 단축 URL 검사 전에 hostname만 먼저 확보 raw_url = context.request.get("original_url") cleaned_url = utils.sanitize_input_url(raw_url) diff --git a/main.py b/main.py index e3c731c..ffc203c 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,9 @@ from core.context import create_initial_context from core.pipeline import run_pipeline from models.receive import AnalysisRequest -from services import response as callback # [추가] +from services import response as callback +import core.pipeline as pip + app = FastAPI() @@ -20,6 +22,8 @@ def analyze(req: AnalysisRequest): # [수정] guest_uuid 기준 context = create_initial_context(req.guest_Uuid, req.url) context.meta["scanned_at"] = datetime.now().isoformat() + pip._send_progress(context, "start", "IN_PROGRESS", "success to create context") + result = run_pipeline(context) From e9f123b3a781e8e85af8bc86935ad492174d53ae Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 12:00:03 +0900 Subject: [PATCH 19/77] =?UTF-8?q?*context=20Uuid=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/context.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/core/context.py b/core/context.py index 6d54151..897648b 100644 --- a/core/context.py +++ b/core/context.py @@ -35,11 +35,11 @@ class AnalysisContext: meta: Dict[str, Any] = field(default_factory=dict) -def create_initial_context(guest_uuid: str, original_url: str) -> AnalysisContext: +def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContext: return AnalysisContext( request={ - "guest_uuid": guest_uuid, # [추가] - "request_id": guest_uuid, # [추가] 기존 builder 호환용 + "guest_Uuid": guest_Uuid, # [추가] + "request_id": guest_Uuid, # [추가] 기존 builder 호환용 "original_url": original_url, }, url={ From e3aeca5510bfb6e64abb0384b0701356a12e69e6 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 12:31:13 +0900 Subject: [PATCH 20/77] =?UTF-8?q?*callback=20=ED=95=A8=EC=88=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/pipeline.py b/core/pipeline.py index c6c7612..f229d66 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -15,8 +15,8 @@ # [추가] 공통 progress 함수 def _send_progress(context, step: str, status: str, message: str): - guest_Uuid = context.request.get("guest_Uuid") - result = callback.send_progress(guest_Uuid, step, status, message) + url = context.request.get("original_url") + result = callback.send_progress(url, step, status, message) if not result.get("ok"): context.meta["warnings"].append({ From 80ac444359d9550eb9df7168a37c40c3388cbbef Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 13:07:24 +0900 Subject: [PATCH 21/77] =?UTF-8?q?*=EC=A4=91=EA=B0=84=EB=B3=B4=EA=B3=A0=20h?= =?UTF-8?q?eader=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/pipeline.py | 9 +++++---- services/response.py | 21 +++++++++------------ 2 files changed, 14 insertions(+), 16 deletions(-) diff --git a/core/pipeline.py b/core/pipeline.py index f229d66..16206d2 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -10,13 +10,14 @@ req_db as db, ) from services import builder, scoring, url_utils as utils -from services import response as callback # [추가] +from services import response as callback +from models.receive import AnalysisRequest # [추가] 공통 progress 함수 -def _send_progress(context, step: str, status: str, message: str): - url = context.request.get("original_url") - result = callback.send_progress(url, step, status, message) +def _send_progress(AnalysisRequest, context, step: str, status: str, message: str): + guest_Uuid = AnalysisRequest.request.get("guest_Uuid") + result = callback.send_progress(guest_Uuid, step, status, message) if not result.get("ok"): context.meta["warnings"].append({ diff --git a/services/response.py b/services/response.py index 22f3f01..82835f6 100644 --- a/services/response.py +++ b/services/response.py @@ -10,14 +10,7 @@ TIMEOUT_SECONDS = 5 -def _build_headers() -> dict: - return { - "X-ML-Secret": ML_SECRET_KEY or "", - "Content-Type": "application/json", - } - - -def _post_json(url: str | None, payload: dict) -> dict: +def _post_json(url: str | None, payload: dict, header:dict) -> dict: # [추가] 통신 자체가 불가능한 경우도 결과로 반환 if not url: return { @@ -37,7 +30,7 @@ def _post_json(url: str | None, payload: dict) -> dict: response = requests.post( url, json=payload, - headers=_build_headers(), + headers=header, timeout=TIMEOUT_SECONDS ) @@ -63,14 +56,18 @@ def _post_json(url: str | None, payload: dict) -> dict: # [추가] 중간보고 -def send_progress(guest_uuid: str, step: str, status: str, message: str) -> dict: +def send_progress(guest_Uuid: str, step: str, status: str, message: str) -> dict: + header = { + "X-ML-Secret": ML_SECRET_KEY or "", + "Content-Type": "application/json", + "guestUuid": guest_Uuid + } payload = { - "guestUuid": guest_uuid, "step": step, "status": status, "message": message } - return _post_json(CALLBACK_PROGRESS_URL, payload) + return _post_json(CALLBACK_PROGRESS_URL, payload, header) # [추가] 최종보고 From 3115fe723483af04797cc0de8dd3e5b30ef33381 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 13:11:46 +0900 Subject: [PATCH 22/77] =?UTF-8?q?*JSON=ED=98=95=EC=8B=9D=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/result.py | 82 +++++++++++++----------- services/builder.py | 153 ++++++++++++++++++++++++-------------------- 2 files changed, 128 insertions(+), 107 deletions(-) diff --git a/models/result.py b/models/result.py index c140899..75e5203 100644 --- a/models/result.py +++ b/models/result.py @@ -6,39 +6,49 @@ @dataclass class AnalysisResult: - # --- 요청/식별 --- - request_id: str # 요청 UUID (현재 입력값 그대로 사용) - - # --- URL 정보 --- - original_url: str # 최초 입력 URL - final_url: Optional[str] = None # 최종 도착 URL - redirect_count: int = 0 # 리다이렉트 횟수 - - # --- URL 유형 --- - scheme_type: Optional[str] = "ANALYZED_WEB" - type_info: Dict[str, Any] = field(default_factory=dict) # URL 상세 정보? - - # --- 시간 --- - scanned_at: Optional[str] = None # 요청 수신 시간 - analyzed_at: Optional[str] = datetime.now(timezone.utc).isoformat() # 분석 완료 시간 - - # --- 보안 기본 정보 --- - is_https: Optional[bool] = None # HTTPS 사용 여부 - - # --- 위험 분석 --- - risk_score: Optional[float] = None # 위험 점수 - risk_level: Optional[str] = None # SAFE / CAUTION / THREAT - threat_label: List[str] = field(default_factory=list) # 탐지된 위협 요소 목록 - - # --- 서버/인증서 --- - server_type: Optional[str] = None - server_location: Optional[str] = None - - cert_valid_yn: Optional[bool] = None - cert_issuer: Optional[str] = None - cert_expiry: Optional[str] = None - - # --- 외부/통계 --- - report_count: Optional[int] = None - block_count: Optional[int] = None - domain_age: Optional[str] = None + analysisTime: str + originalUrl: str + + https: dict = field(default_factory=lambda: { + "isSecure": False + }) + + shortUrl: dict = field(default_factory=lambda: { + "isShortened": False + }) + + ml: dict = field(default_factory=lambda: { + "threats": [], + "score": 0 + }) + + externalApi: dict = field(default_factory=lambda: { + "checked": False, + "provider": None, + "result": None + }) + + internalDb: dict = field(default_factory=lambda: { + "exists": False, + "reportCount": None, + "blockCount": None + }) + + redirect: dict = field(default_factory=lambda: { + "finalUrl": None, + "redirectCount": 0 + }) + + serverInfo: dict = field(default_factory=lambda: { + "type": None, + "location": None, + "certificate": { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None + } + }) + + score: int = 0 + riskLevel: str = "SAFE" \ No newline at end of file diff --git a/services/builder.py b/services/builder.py index da03c6a..caf9019 100644 --- a/services/builder.py +++ b/services/builder.py @@ -1,98 +1,109 @@ # 검사 결과를 정리하고, 전송할 수 있는 형태로 build합니다. +from datetime import datetime from core.context import AnalysisContext from models.result import AnalysisResult -from typing import List, Dict, Any -# 각 검사별 결과 라벨 합치기 -def build_threat_label(context: AnalysisContext) -> list[str]: + +def build_threats(context: AnalysisContext) -> list[str]: labels = [] - shortened = context.detection.get("shortened_url", {}) - if shortened.get("detected"): + if context.detection.get("shortened_url", {}).get("detected"): labels.append("shortened_url") - rule_based = context.detection.get("rule_based", {}) - for flag in rule_based.get("flags", []): + for flag in context.detection.get("rule_based", {}).get("flags", []): if flag not in labels: labels.append(flag) - safe_browsing = context.external.get("safe_browsing", {}) - for label in safe_browsing.get("threat_labels", []): + for label in context.external.get("safe_browsing", {}).get("threat_labels", []): if label not in labels: labels.append(label) - ml_result = context.detection.get("ml", {}) - for threat in ml_result.get("threats", []): + for threat in context.detection.get("ml", {}).get("threats", []): if threat not in labels: labels.append(threat) return labels -# result에 데이터 채우기 +def build_external_result(context: AnalysisContext) -> str | None: + safe_browsing = context.external.get("safe_browsing", {}) + + status_type = safe_browsing.get("status_type") + result_type = safe_browsing.get("result_type") + + if status_type in ("timeout", "client_error", "server_error", "request_failed"): + return status_type + if result_type is not None: + return str(result_type) + if safe_browsing.get("is_threat"): + return "THREAT" + if status_type is not None: + return "SAFE" + return None + + def build_result(context: AnalysisContext) -> AnalysisResult: - return AnalysisResult ( - request_id=context.request["request_id"], - - original_url=context.url["original_url"], - final_url=context.redirect.get("final_url"), - redirect_count=context.redirect.get("redirect_count", 0), - - scheme_type="ANALYZED_WEB", - type_info={ - "scheme": context.url.get("scheme"), - "hostname": context.url.get("hostname"), - "port": context.url.get("port"), - }, - - scanned_at=context.meta.get("scanned_at"), - analyzed_at=context.meta.get("analyzed_at"), - - is_https=context.url.get("is_https"), - - risk_score=context.scoring.get("score"), - risk_level=context.scoring.get("risk_level"), - threat_label=build_threat_label(context), - - server_type=context.external.get("server_type"), - server_location=context.external.get("server_location"), - cert_valid_yn=context.external.get("cert_valid_yn"), - cert_issuer=context.external.get("cert_issuer"), - cert_expiry=context.external.get("cert_expiry"), - - report_count=context.external.get("report_count"), - block_count=context.external.get("block_count"), - domain_age=context.external.get("domain_age"), + safe_browsing = context.external.get("safe_browsing", {}) + ml_result = context.detection.get("ml", {}) + report_data = context.report_exist.get("data") or {} + + result = AnalysisResult( + analysisTime=context.meta.get("analyzed_at") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + originalUrl=context.url.get("original_url") or "" ) + result.https["isSecure"] = bool(context.url.get("is_https", False)) + + result.shortUrl["isShortened"] = bool( + context.flags.get("is_shortened_url", False) + or context.detection.get("shortened_url", {}).get("detected", False) + ) + + result.ml["threats"] = build_threats(context) + result.ml["score"] = int(ml_result.get("score", context.scoring.get("score", 0)) or 0) + + result.externalApi["checked"] = ( + safe_browsing.get("status_type") is not None + or safe_browsing.get("result_type") is not None + ) + result.externalApi["provider"] = "Google Safe Browsing" + result.externalApi["result"] = build_external_result(context) + + result.internalDb["exists"] = bool(context.report_exist.get("exists", False)) + result.internalDb["reportCount"] = report_data.get("report_count") + result.internalDb["blockCount"] = report_data.get("block_count") + + result.redirect["finalUrl"] = ( + context.redirect.get("final_url") + or context.url.get("normalized_url") + or context.url.get("original_url") + ) + result.redirect["redirectCount"] = int(context.redirect.get("redirect_count", 0) or 0) + + result.serverInfo["type"] = context.external.get("server_type") + result.serverInfo["location"] = context.external.get("server_location") + result.serverInfo["certificate"]["valid"] = bool(context.external.get("cert_valid_yn", False)) + result.serverInfo["certificate"]["issuer"] = context.external.get("cert_issuer") + result.serverInfo["certificate"]["validFrom"] = context.external.get("cert_valid_from") + result.serverInfo["certificate"]["validTo"] = context.external.get("cert_expiry") + + result.score = int(context.scoring.get("score", 0) or 0) + result.riskLevel = context.scoring.get("risk_level") or "SAFE" + + return result + def build_JSON(result: AnalysisResult) -> dict: return { - "analysisTime": result.analyzed_at, - "targetUrl": result.original_url, - "https": { - "isSecure": result.is_https - }, - "ml": { - "threats": result.threat_label - }, - "redirect": { - "finalUrl": result.final_url, - "redirectCount": result.redirect_count - }, - "serverInfo": { - "type": result.server_type, - "location": result.server_location, - "certificate": { - "valid": result.cert_valid_yn, - "issuer": result.cert_issuer, - "validTo": result.cert_expiry - } - }, - "internalDb": { - "reportCount": result.report_count, - "blockCount": result.block_count - }, - "score": result.risk_score, - "riskLevel": result.risk_level + "analysisTime": result.analysisTime, + "originalUrl": result.originalUrl, + "https": result.https, + "shortUrl": result.shortUrl, + "ml": result.ml, + "externalApi": result.externalApi, + "internalDb": result.internalDb, + "redirect": result.redirect, + "serverInfo": result.serverInfo, + "score": result.score, + "riskLevel": result.riskLevel } \ No newline at end of file From 6472070bcc090c63d797ba45e7704f4891907550 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 13:28:56 +0900 Subject: [PATCH 23/77] =?UTF-8?q?*=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/pipeline.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/pipeline.py b/core/pipeline.py index 16206d2..4f3114b 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -15,8 +15,8 @@ # [추가] 공통 progress 함수 -def _send_progress(AnalysisRequest, context, step: str, status: str, message: str): - guest_Uuid = AnalysisRequest.request.get("guest_Uuid") +def _send_progress(context, step: str, status: str, message: str): + guest_Uuid = context.request.get("guest_Uuid") result = callback.send_progress(guest_Uuid, step, status, message) if not result.get("ok"): From baf8822199142256d2aa726d165ede3d43971f33 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 13:45:45 +0900 Subject: [PATCH 24/77] =?UTF-8?q?*response=20=EB=A1=A4=EB=B0=B1->3e5732d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/response.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/services/response.py b/services/response.py index 82835f6..991ea81 100644 --- a/services/response.py +++ b/services/response.py @@ -10,7 +10,14 @@ TIMEOUT_SECONDS = 5 -def _post_json(url: str | None, payload: dict, header:dict) -> dict: +def _build_headers() -> dict: + return { + "X-ML-Secret": ML_SECRET_KEY or "", + "Content-Type": "application/json", + } + + +def _post_json(url: str | None, payload: dict) -> dict: # [추가] 통신 자체가 불가능한 경우도 결과로 반환 if not url: return { @@ -30,7 +37,7 @@ def _post_json(url: str | None, payload: dict, header:dict) -> dict: response = requests.post( url, json=payload, - headers=header, + headers=_build_headers(), timeout=TIMEOUT_SECONDS ) @@ -57,17 +64,13 @@ def _post_json(url: str | None, payload: dict, header:dict) -> dict: # [추가] 중간보고 def send_progress(guest_Uuid: str, step: str, status: str, message: str) -> dict: - header = { - "X-ML-Secret": ML_SECRET_KEY or "", - "Content-Type": "application/json", - "guestUuid": guest_Uuid - } payload = { + "guestUuid": guest_Uuid, "step": step, "status": status, "message": message } - return _post_json(CALLBACK_PROGRESS_URL, payload, header) + return _post_json(CALLBACK_PROGRESS_URL, payload) # [추가] 최종보고 From 46425ba942f7b82902c5c0b96cebe7f294cf3b21 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 13:56:22 +0900 Subject: [PATCH 25/77] =?UTF-8?q?*ml=EB=B6=84=EC=84=9D=20=EC=A0=90?= =?UTF-8?q?=EC=88=98=20=EC=9E=90=EB=A3=8C=ED=98=95=20float->int?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/ai_analyze.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py index 1a87090..7cbc4bc 100644 --- a/analyzers/ai_analyze.py +++ b/analyzers/ai_analyze.py @@ -30,17 +30,17 @@ def ai_analyze(context: AnalysisContext) -> dict: def run_ml_inference(target_url: str | None) -> dict: if not target_url: return { - "score": 0.0, + "score": 0, "threats": [] } return { - "score": 0.61, + "score": 61, "threats": ["dummy_ml_threat"] } -def build_ml_result(score: float = 0.0, threats: list | None = None) -> dict: +def build_ml_result(score: int = 0, threats: list | None = None) -> dict: if threats is None: threats = [] From 878277b84c9b75f99b9890f468566466c11c5f0b Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 15:04:00 +0900 Subject: [PATCH 26/77] =?UTF-8?q?*=EB=B0=98=ED=99=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 4 +-- services/response.py | 58 +++++++++++++++++++++++--------------------- 2 files changed, 32 insertions(+), 30 deletions(-) diff --git a/main.py b/main.py index ffc203c..37c52fe 100644 --- a/main.py +++ b/main.py @@ -19,16 +19,14 @@ def root(): @app.post("/analyze") def analyze(req: AnalysisRequest): - # [수정] guest_uuid 기준 context = create_initial_context(req.guest_Uuid, req.url) context.meta["scanned_at"] = datetime.now().isoformat() pip._send_progress(context, "start", "IN_PROGRESS", "success to create context") - result = run_pipeline(context) # [추가] 최종 결과 callback 전송 - callback_result = callback.send_final_result(result) + callback_result = callback.send_final_result(req.guest_Uuid, result) if not callback_result.get("ok"): context.meta["warnings"].append({ diff --git a/services/response.py b/services/response.py index 991ea81..198989b 100644 --- a/services/response.py +++ b/services/response.py @@ -17,21 +17,21 @@ def _build_headers() -> dict: } -def _post_json(url: str | None, payload: dict) -> dict: - # [추가] 통신 자체가 불가능한 경우도 결과로 반환 +def _post_json(url: str | None, payload: dict) -> bool: + # URL 없으면 → 실패 JSON 생성 후 종료 if not url: - return { - "ok": False, - "status_code": None, - "error": "callback_url_missing" - } + payload.update({ + "status": "ERROR", + "message": "callback_url_missing" + }) + return False if not ML_SECRET_KEY: - return { - "ok": False, - "status_code": None, - "error": "ml_secret_missing" - } + payload.update({ + "status": "ERROR", + "message": "ml_secret_missing" + }) + return False try: response = requests.post( @@ -41,25 +41,29 @@ def _post_json(url: str | None, payload: dict) -> dict: timeout=TIMEOUT_SECONDS ) - return { - "ok": response.ok, - "status_code": response.status_code, - "error": None if response.ok else response.text - } + # 실패 시에도 payload에 반영 + if not response.ok: + payload.update({ + "status": "ERROR", + "message": f"http_{response.status_code}" + }) + return False + + return True except requests.exceptions.Timeout: - return { - "ok": False, - "status_code": None, - "error": "timeout" - } + payload.update({ + "status": "ERROR", + "message": "timeout" + }) + return False except requests.exceptions.RequestException as e: - return { - "ok": False, - "status_code": None, - "error": str(e) - } + payload.update({ + "status": "ERROR", + "message": str(e) + }) + return False # [추가] 중간보고 From f3d863fa686a10f668125e65c40e9214e26351bb Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 15:26:53 +0900 Subject: [PATCH 27/77] =?UTF-8?q?*=ED=86=B5=EC=8B=A0=20=EC=83=81=ED=83=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 7 +++++-- services/response.py | 8 +++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 37c52fe..6758737 100644 --- a/main.py +++ b/main.py @@ -6,7 +6,6 @@ from core.pipeline import run_pipeline from models.receive import AnalysisRequest from services import response as callback -import core.pipeline as pip app = FastAPI() @@ -14,21 +13,25 @@ @app.get("/") def root(): + print("connected") return {"message": "server is running"} @app.post("/analyze") def analyze(req: AnalysisRequest): context = create_initial_context(req.guest_Uuid, req.url) + print("context created") context.meta["scanned_at"] = datetime.now().isoformat() - pip._send_progress(context, "start", "IN_PROGRESS", "success to create context") + print("start pipeline") result = run_pipeline(context) # [추가] 최종 결과 callback 전송 + print("posting final result...") callback_result = callback.send_final_result(req.guest_Uuid, result) if not callback_result.get("ok"): + print("fianl callback failed") context.meta["warnings"].append({ "type": "final_callback_failed", "error": callback_result.get("error") diff --git a/services/response.py b/services/response.py index 198989b..d3caac2 100644 --- a/services/response.py +++ b/services/response.py @@ -17,13 +17,14 @@ def _build_headers() -> dict: } -def _post_json(url: str | None, payload: dict) -> bool: +def _post_json(url: str | None, payload: dict): # URL 없으면 → 실패 JSON 생성 후 종료 if not url: payload.update({ "status": "ERROR", "message": "callback_url_missing" }) + print("post_json:", payload.message) return False if not ML_SECRET_KEY: @@ -31,6 +32,7 @@ def _post_json(url: str | None, payload: dict) -> bool: "status": "ERROR", "message": "ml_secret_missing" }) + print("post_json:", payload.message) return False try: @@ -40,6 +42,7 @@ def _post_json(url: str | None, payload: dict) -> bool: headers=_build_headers(), timeout=TIMEOUT_SECONDS ) + print("post_json:", payload.message) # 실패 시에도 payload에 반영 if not response.ok: @@ -47,6 +50,7 @@ def _post_json(url: str | None, payload: dict) -> bool: "status": "ERROR", "message": f"http_{response.status_code}" }) + print("post_json:", payload.message) return False return True @@ -56,6 +60,7 @@ def _post_json(url: str | None, payload: dict) -> bool: "status": "ERROR", "message": "timeout" }) + print("post_json:", payload.message) return False except requests.exceptions.RequestException as e: @@ -63,6 +68,7 @@ def _post_json(url: str | None, payload: dict) -> bool: "status": "ERROR", "message": str(e) }) + print("post_json:", payload.message) return False From 092dff7c4ed0524423f32b99afda622285ea3bdd Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 15:32:16 +0900 Subject: [PATCH 28/77] =?UTF-8?q?*payload=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/response.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/services/response.py b/services/response.py index d3caac2..8e9a3d5 100644 --- a/services/response.py +++ b/services/response.py @@ -17,14 +17,14 @@ def _build_headers() -> dict: } -def _post_json(url: str | None, payload: dict): +def _post_json(url: str | None, payload: dict) -> bool: # URL 없으면 → 실패 JSON 생성 후 종료 if not url: payload.update({ "status": "ERROR", "message": "callback_url_missing" }) - print("post_json:", payload.message) + print("post_json:", payload["message"]) return False if not ML_SECRET_KEY: @@ -32,7 +32,7 @@ def _post_json(url: str | None, payload: dict): "status": "ERROR", "message": "ml_secret_missing" }) - print("post_json:", payload.message) + print("post_json:", payload["message"]) return False try: @@ -42,7 +42,7 @@ def _post_json(url: str | None, payload: dict): headers=_build_headers(), timeout=TIMEOUT_SECONDS ) - print("post_json:", payload.message) + print("post_json:", payload["message"]) # 실패 시에도 payload에 반영 if not response.ok: @@ -50,7 +50,7 @@ def _post_json(url: str | None, payload: dict): "status": "ERROR", "message": f"http_{response.status_code}" }) - print("post_json:", payload.message) + print("post_json:", payload["message"]) return False return True @@ -60,7 +60,7 @@ def _post_json(url: str | None, payload: dict): "status": "ERROR", "message": "timeout" }) - print("post_json:", payload.message) + print("post_json:", payload["message"]) return False except requests.exceptions.RequestException as e: @@ -68,7 +68,7 @@ def _post_json(url: str | None, payload: dict): "status": "ERROR", "message": str(e) }) - print("post_json:", payload.message) + print("post_json:", payload["message"]) return False From a244ddb8d3cbb77734e703736ce85047bd65c006 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 15:37:38 +0900 Subject: [PATCH 29/77] *dict --- services/response.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/response.py b/services/response.py index 8e9a3d5..bc8eefc 100644 --- a/services/response.py +++ b/services/response.py @@ -17,7 +17,7 @@ def _build_headers() -> dict: } -def _post_json(url: str | None, payload: dict) -> bool: +def _post_json(url: str | None, payload: dict) -> dict: # URL 없으면 → 실패 JSON 생성 후 종료 if not url: payload.update({ @@ -42,7 +42,7 @@ def _post_json(url: str | None, payload: dict) -> bool: headers=_build_headers(), timeout=TIMEOUT_SECONDS ) - print("post_json:", payload["message"]) + print("post_json:", payload["message"]) # 실패 시에도 payload에 반영 if not response.ok: From ca7fe770dde6044e2715ac63e9edd77d31da3370 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 15:47:02 +0900 Subject: [PATCH 30/77] *dict2 --- core/pipeline.py | 11 ++--------- services/response.py | 3 +-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/core/pipeline.py b/core/pipeline.py index 4f3114b..0a36f0b 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -17,15 +17,8 @@ # [추가] 공통 progress 함수 def _send_progress(context, step: str, status: str, message: str): guest_Uuid = context.request.get("guest_Uuid") - result = callback.send_progress(guest_Uuid, step, status, message) - - if not result.get("ok"): - context.meta["warnings"].append({ - "type": "progress_callback_failed", - "step": step, - "status": status, - "error": result.get("error") - }) + callback.send_progress(guest_Uuid, step, status, message) + # [추가] 공통 최종 JSON 생성 diff --git a/services/response.py b/services/response.py index bc8eefc..ed38173 100644 --- a/services/response.py +++ b/services/response.py @@ -52,8 +52,7 @@ def _post_json(url: str | None, payload: dict) -> dict: }) print("post_json:", payload["message"]) return False - - return True + return payload except requests.exceptions.Timeout: payload.update({ From 7c9d25890b7519a0a91204806bd242e6f6b717f9 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 15:51:57 +0900 Subject: [PATCH 31/77] =?UTF-8?q?*=EB=B0=A9=ED=95=B4=EB=AC=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/main.py b/main.py index 6758737..1f181ab 100644 --- a/main.py +++ b/main.py @@ -28,14 +28,7 @@ def analyze(req: AnalysisRequest): # [추가] 최종 결과 callback 전송 print("posting final result...") - callback_result = callback.send_final_result(req.guest_Uuid, result) - - if not callback_result.get("ok"): - print("fianl callback failed") - context.meta["warnings"].append({ - "type": "final_callback_failed", - "error": callback_result.get("error") - }) + callback.send_final_result(result) # [추가] 로컬 확인용으로 callback 상태 포함 return { From 47c1c4897a3481e29341656b33b5d5f874733e6b Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 15:52:41 +0900 Subject: [PATCH 32/77] * --- main.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/main.py b/main.py index 1f181ab..b325489 100644 --- a/main.py +++ b/main.py @@ -31,7 +31,4 @@ def analyze(req: AnalysisRequest): callback.send_final_result(result) # [추가] 로컬 확인용으로 callback 상태 포함 - return { - "analysisResult": result, - "callback": callback_result - } \ No newline at end of file + return print("analyze done") From de0046c4c234120f14f743b3ffb2e698ddb31633 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 17 Apr 2026 15:58:33 +0900 Subject: [PATCH 33/77] =?UTF-8?q?*=EC=A4=91=EA=B0=84=20=EA=B3=BC=EC=A0=95?= =?UTF-8?q?=EC=9D=80=20=EC=99=9C=20=EC=95=88=20=EB=9C=B0=EA=B9=8C....?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/response.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/response.py b/services/response.py index ed38173..f9d9a19 100644 --- a/services/response.py +++ b/services/response.py @@ -24,7 +24,7 @@ def _post_json(url: str | None, payload: dict) -> dict: "status": "ERROR", "message": "callback_url_missing" }) - print("post_json:", payload["message"]) + print("post_json: callback url missing") return False if not ML_SECRET_KEY: @@ -32,7 +32,7 @@ def _post_json(url: str | None, payload: dict) -> dict: "status": "ERROR", "message": "ml_secret_missing" }) - print("post_json:", payload["message"]) + print("post_json: ml key missing") return False try: @@ -42,7 +42,7 @@ def _post_json(url: str | None, payload: dict) -> dict: headers=_build_headers(), timeout=TIMEOUT_SECONDS ) - print("post_json:", payload["message"]) + print("post_json: progress response") # 실패 시에도 payload에 반영 if not response.ok: @@ -50,7 +50,7 @@ def _post_json(url: str | None, payload: dict) -> dict: "status": "ERROR", "message": f"http_{response.status_code}" }) - print("post_json:", payload["message"]) + print("post_json: https error") return False return payload @@ -59,7 +59,7 @@ def _post_json(url: str | None, payload: dict) -> dict: "status": "ERROR", "message": "timeout" }) - print("post_json:", payload["message"]) + print("post_json: timeout") return False except requests.exceptions.RequestException as e: @@ -67,7 +67,7 @@ def _post_json(url: str | None, payload: dict) -> dict: "status": "ERROR", "message": str(e) }) - print("post_json:", payload["message"]) + print("post_json: ERROR") return False From 550fb8333574001affa0261d6682c663580af407 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sat, 18 Apr 2026 15:57:28 +0900 Subject: [PATCH 34/77] =?UTF-8?q?*=EC=8B=9C=EA=B0=84=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/pipeline.py | 1 - services/builder.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/core/pipeline.py b/core/pipeline.py index 0a36f0b..e7afdb1 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -23,7 +23,6 @@ def _send_progress(context, step: str, status: str, message: str): # [추가] 공통 최종 JSON 생성 def _build_final_json(context) -> dict: - context.meta["analyzed_at"] = datetime.now().isoformat() result = builder.build_result(context) return builder.build_JSON(result) diff --git a/services/builder.py b/services/builder.py index caf9019..ba808bc 100644 --- a/services/builder.py +++ b/services/builder.py @@ -48,7 +48,7 @@ def build_result(context: AnalysisContext) -> AnalysisResult: report_data = context.report_exist.get("data") or {} result = AnalysisResult( - analysisTime=context.meta.get("analyzed_at") or datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + analysisTime=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), originalUrl=context.url.get("original_url") or "" ) From 0ac5ed8e44e56e425f4c243886ed77d5bd34fb42 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sat, 18 Apr 2026 16:09:17 +0900 Subject: [PATCH 35/77] =?UTF-8?q?*result=ED=97=A4=EB=8D=94=EC=97=90=20gues?= =?UTF-8?q?tUuid=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 3 ++- services/response.py | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index b325489..ffd6f22 100644 --- a/main.py +++ b/main.py @@ -28,7 +28,8 @@ def analyze(req: AnalysisRequest): # [추가] 최종 결과 callback 전송 print("posting final result...") - callback.send_final_result(result) + guest_Uuid = context.request.get("guest_Uuid") + callback.send_final_result(guest_Uuid, result) # [추가] 로컬 확인용으로 callback 상태 포함 return print("analyze done") diff --git a/services/response.py b/services/response.py index f9d9a19..37aa278 100644 --- a/services/response.py +++ b/services/response.py @@ -10,14 +10,16 @@ TIMEOUT_SECONDS = 5 -def _build_headers() -> dict: +def _build_headers(guest_Uuid: str) -> dict: return { "X-ML-Secret": ML_SECRET_KEY or "", "Content-Type": "application/json", + "guestUuid": guest_Uuid, + } -def _post_json(url: str | None, payload: dict) -> dict: +def _post_json(url: str | None, guest_Uuid: str, payload: dict) -> dict: # URL 없으면 → 실패 JSON 생성 후 종료 if not url: payload.update({ @@ -39,7 +41,7 @@ def _post_json(url: str | None, payload: dict) -> dict: response = requests.post( url, json=payload, - headers=_build_headers(), + headers=_build_headers(guest_Uuid), timeout=TIMEOUT_SECONDS ) print("post_json: progress response") @@ -83,5 +85,5 @@ def send_progress(guest_Uuid: str, step: str, status: str, message: str) -> dict # [추가] 최종보고 -def send_final_result(result_json: dict) -> dict: - return _post_json(CALLBACK_FINAL_URL, result_json) \ No newline at end of file +def send_final_result(guest_Uuid: str, result_json: dict) -> dict: + return _post_json(CALLBACK_FINAL_URL, guest_Uuid, result_json) \ No newline at end of file From f7575494fbcbf9c529f25563015b02c290818983 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sat, 18 Apr 2026 16:15:51 +0900 Subject: [PATCH 36/77] =?UTF-8?q?*=EC=88=98=EC=A0=951?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/response.py b/services/response.py index 37aa278..86c18ea 100644 --- a/services/response.py +++ b/services/response.py @@ -81,7 +81,7 @@ def send_progress(guest_Uuid: str, step: str, status: str, message: str) -> dict "status": status, "message": message } - return _post_json(CALLBACK_PROGRESS_URL, payload) + return _post_json(CALLBACK_PROGRESS_URL, guest_Uuid, payload) # [추가] 최종보고 From 1ed802e38b050c009f82ee630d30871f9f474a15 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sat, 18 Apr 2026 16:28:34 +0900 Subject: [PATCH 37/77] =?UTF-8?q?*guestUuid=20=EC=B5=9C=EC=A2=85=20json?= =?UTF-8?q?=EC=97=90=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- models/result.py | 1 + services/builder.py | 1 + services/response.py | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/models/result.py b/models/result.py index 75e5203..89d880f 100644 --- a/models/result.py +++ b/models/result.py @@ -6,6 +6,7 @@ @dataclass class AnalysisResult: + guestUuid: str analysisTime: str originalUrl: str diff --git a/services/builder.py b/services/builder.py index ba808bc..704ab72 100644 --- a/services/builder.py +++ b/services/builder.py @@ -43,6 +43,7 @@ def build_external_result(context: AnalysisContext) -> str | None: def build_result(context: AnalysisContext) -> AnalysisResult: + guestUuid = context.request.get("guest_Uuid") safe_browsing = context.external.get("safe_browsing", {}) ml_result = context.detection.get("ml", {}) report_data = context.report_exist.get("data") or {} diff --git a/services/response.py b/services/response.py index 86c18ea..b0bcc6c 100644 --- a/services/response.py +++ b/services/response.py @@ -15,7 +15,6 @@ def _build_headers(guest_Uuid: str) -> dict: "X-ML-Secret": ML_SECRET_KEY or "", "Content-Type": "application/json", "guestUuid": guest_Uuid, - } From 0a171108392ca7a4881b185f0def661153ad6503 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sat, 18 Apr 2026 17:03:19 +0900 Subject: [PATCH 38/77] * --- services/response.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/response.py b/services/response.py index b0bcc6c..531c047 100644 --- a/services/response.py +++ b/services/response.py @@ -14,7 +14,7 @@ def _build_headers(guest_Uuid: str) -> dict: return { "X-ML-Secret": ML_SECRET_KEY or "", "Content-Type": "application/json", - "guestUuid": guest_Uuid, + "guest_uuid": guest_Uuid, } From c86df182ebfd84f28da840ab224131844cea8ec3 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sat, 18 Apr 2026 17:18:11 +0900 Subject: [PATCH 39/77] =?UTF-8?q?*=EB=B9=8C=EB=8D=94=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/services/builder.py b/services/builder.py index 704ab72..87e9d7b 100644 --- a/services/builder.py +++ b/services/builder.py @@ -96,6 +96,7 @@ def build_result(context: AnalysisContext) -> AnalysisResult: def build_JSON(result: AnalysisResult) -> dict: return { + "guestUuid": result.guestUuid, "analysisTime": result.analysisTime, "originalUrl": result.originalUrl, "https": result.https, From da1ae0a736c5e677d19de4ee119502204903e335 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sat, 18 Apr 2026 17:26:58 +0900 Subject: [PATCH 40/77] =?UTF-8?q?*guestUuid=20=EB=93=A4=EC=96=B4=EA=B0=80?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/builder.py b/services/builder.py index 87e9d7b..8c6b251 100644 --- a/services/builder.py +++ b/services/builder.py @@ -43,12 +43,12 @@ def build_external_result(context: AnalysisContext) -> str | None: def build_result(context: AnalysisContext) -> AnalysisResult: - guestUuid = context.request.get("guest_Uuid") safe_browsing = context.external.get("safe_browsing", {}) ml_result = context.detection.get("ml", {}) report_data = context.report_exist.get("data") or {} result = AnalysisResult( + guestUuid = context.request.get("guest_Uuid", {}), analysisTime=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), originalUrl=context.url.get("original_url") or "" ) From 3761f2ea491bb066ce46ec3bc11ef6fc71911409 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 24 Apr 2026 05:40:25 +0900 Subject: [PATCH 41/77] =?UTF-8?q?*=EC=9C=84=ED=98=91=EC=9A=94=EC=86=8C?= =?UTF-8?q?=EA=B0=80=20ml=ED=95=84=EB=93=9C=EC=97=90=EB=A7=8C=20=EC=9E=88?= =?UTF-8?q?=EB=8D=98=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95,=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=84=9C=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B0=80=EB=8A=A5-=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_server.py | 123 ++++++++++++++++++++++++++++++++++++++++ core/context.py | 16 ++++++ core/pipeline.py | 13 +++-- models/result.py | 7 ++- services/builder.py | 24 ++++---- services/scoring.py | 7 +-- 6 files changed, 167 insertions(+), 23 deletions(-) create mode 100644 analyzers/req_server.py diff --git a/analyzers/req_server.py b/analyzers/req_server.py new file mode 100644 index 0000000..21bf060 --- /dev/null +++ b/analyzers/req_server.py @@ -0,0 +1,123 @@ +# 인증서 조회 +# 확장 가능: issuer 기반 탐지 로직 +import socket +import ssl + +from core.context import AnalysisContext + +STATUS_SUCCESS = "success" +STATUS_TIMEOUT = "timeout" +STATUS_SSL_ERROR = "ssl_error" +STATUS_VERIFY_FAILED = "verify_failed" +STATUS_REQUEST_FAILED = "request_failed" +STATUS_INVALID_INPUT = "invalid_input" + +CERT_TIMEOUT = 5 + + +def req_server(context: AnalysisContext) -> dict: + hostname = context.url.get("hostname") + + raw = cert_req_api(hostname) + parsed = parse_cert_result(raw) + + return build_server_result(parsed) + + +def cert_req_api(hostname: str | None) -> dict: + if not hostname: + return {"type": STATUS_INVALID_INPUT} + + try: + ssl_context = ssl.create_default_context() + + with socket.create_connection((hostname, 443), timeout=CERT_TIMEOUT) as sock: + with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: + cert = ssock.getpeercert() + + return { + "type": STATUS_SUCCESS, + "data": cert + } + + except socket.timeout: + return {"type": STATUS_TIMEOUT} + + except ssl.SSLCertVerificationError as e: + return { + "type": STATUS_VERIFY_FAILED, + "error": str(e) + } + + except ssl.SSLError as e: + return { + "type": STATUS_SSL_ERROR, + "error": str(e) + } + + except OSError as e: + return { + "type": STATUS_REQUEST_FAILED, + "error": str(e) + } + + +def parse_cert_result(raw: dict) -> dict: + rtype = raw.get("type") + + if rtype != STATUS_SUCCESS: + return { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": [rtype], + "error": raw.get("error") + } + + cert = raw.get("data") or {} + + issuer = parse_issuer(cert.get("issuer")) + valid_from = cert.get("notBefore") + valid_to = cert.get("notAfter") + + return { + "valid": True, + "issuer": issuer, + "validFrom": valid_from, + "validTo": valid_to, + "status_type": [STATUS_SUCCESS] + } + + +def build_server_result(parsed: dict) -> dict: + base = { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": None, + "error": [] + } + + base["certificate"].update(parsed) + return base + + +def parse_issuer(issuer_tuple) -> str | None: + if not issuer_tuple: + return None + + flat = {} + + for group in issuer_tuple: + for key, value in group: + flat[key] = value + + if flat.get("organizationName"): + return flat["organizationName"] + + if flat.get("commonName"): + return flat["commonName"] + + return None \ No newline at end of file diff --git a/core/context.py b/core/context.py index 897648b..0c4e0c6 100644 --- a/core/context.py +++ b/core/context.py @@ -19,6 +19,8 @@ class AnalysisContext: # 룰 기반 검사기를 거친 결과 detection: Dict[str, Any] = field(default_factory=dict) + ml: Dict[str, Any] = field(default_factory=dict) + # flags : 판정 및 로직 분기 판별에서 사용하기 위한 플래그 flags: Dict[str, Any] = field(default_factory=dict) @@ -28,6 +30,8 @@ class AnalysisContext: # 외부 시스템 연동 결과용 묶음 external: Dict[str, Any] = field(default_factory=dict) + certificate: Dict[str, Any] = field(default_factory=dict) + # [수정] DB 조회 결과 필드 추가 report_exist: Dict[str, Any] = field(default_factory=dict) @@ -81,6 +85,10 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex }, } }, + ml = { + "score": None, + "threats": [] + }, flags={ "normalized": False, "is_shortened_url": False, @@ -105,6 +113,14 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex "block_count": None, "domain_age": None }, + certificate = { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": None, + "error": [] + }, report_exist={ "exists": False, "data": None diff --git a/core/pipeline.py b/core/pipeline.py index e7afdb1..d00fdf5 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -8,11 +8,10 @@ ai_analyze as ai, req_api as api, req_db as db, + req_server as server ) from services import builder, scoring, url_utils as utils from services import response as callback -from models.receive import AnalysisRequest - # [추가] 공통 progress 함수 def _send_progress(context, step: str, status: str, message: str): @@ -69,9 +68,10 @@ def run_shortened_pipeline(context): if context.report_exist.get("exists"): return context.report_exist.get("data") - _send_progress(context, "SAFE_BROWSING", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") + _send_progress(context, "API_REQ", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") context.external = api.req_api(context) - _send_progress(context, "SAFE_BROWSING", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") + context.certificate = server.req_server(context) + _send_progress(context, "API_REQ", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") safe_browsing = context.external.get("safe_browsing", {}) if safe_browsing.get("is_threat"): @@ -104,9 +104,10 @@ def run_normal_pipeline(context): if context.report_exist.get("exists"): return context.report_exist.get("data") - _send_progress(context, "SAFE_BROWSING", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") + _send_progress(context, "API_REQ", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") context.external = api.req_api(context) - _send_progress(context, "SAFE_BROWSING", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") + context.certificate = server.req_server(context) + _send_progress(context, "API_REQ", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") safe_browsing = context.external.get("safe_browsing", {}) if safe_browsing.get("is_threat"): diff --git a/models/result.py b/models/result.py index 89d880f..db9ccbf 100644 --- a/models/result.py +++ b/models/result.py @@ -19,8 +19,8 @@ class AnalysisResult: }) ml: dict = field(default_factory=lambda: { - "threats": [], - "score": 0 + "score": 0, + "threats": [] }) externalApi: dict = field(default_factory=lambda: { @@ -40,6 +40,9 @@ class AnalysisResult: "redirectCount": 0 }) + threats: dict = field(default_factory=lambda: { + }) + serverInfo: dict = field(default_factory=lambda: { "type": None, "location": None, diff --git a/services/builder.py b/services/builder.py index 8c6b251..cba4885 100644 --- a/services/builder.py +++ b/services/builder.py @@ -18,9 +18,9 @@ def build_threats(context: AnalysisContext) -> list[str]: if label not in labels: labels.append(label) - for threat in context.detection.get("ml", {}).get("threats", []): - if threat not in labels: - labels.append(threat) + for error in context.certificate.get("error", []): + if error not in labels: + labels.append(error) return labels @@ -31,7 +31,9 @@ def build_external_result(context: AnalysisContext) -> str | None: status_type = safe_browsing.get("status_type") result_type = safe_browsing.get("result_type") - if status_type in ("timeout", "client_error", "server_error", "request_failed"): + status = status_type[1] if isinstance(status_type, (list, tuple)) and len(status_type) > 1 else status_type + + if status in ("timeout", "client_error", "server_error", "request_failed"): return status_type if result_type is not None: return str(result_type) @@ -44,7 +46,6 @@ def build_external_result(context: AnalysisContext) -> str | None: def build_result(context: AnalysisContext) -> AnalysisResult: safe_browsing = context.external.get("safe_browsing", {}) - ml_result = context.detection.get("ml", {}) report_data = context.report_exist.get("data") or {} result = AnalysisResult( @@ -60,8 +61,8 @@ def build_result(context: AnalysisContext) -> AnalysisResult: or context.detection.get("shortened_url", {}).get("detected", False) ) - result.ml["threats"] = build_threats(context) - result.ml["score"] = int(ml_result.get("score", context.scoring.get("score", 0)) or 0) + result.threats = build_threats(context) + result.ml = context.ml or {} result.externalApi["checked"] = ( safe_browsing.get("status_type") is not None @@ -83,10 +84,10 @@ def build_result(context: AnalysisContext) -> AnalysisResult: result.serverInfo["type"] = context.external.get("server_type") result.serverInfo["location"] = context.external.get("server_location") - result.serverInfo["certificate"]["valid"] = bool(context.external.get("cert_valid_yn", False)) - result.serverInfo["certificate"]["issuer"] = context.external.get("cert_issuer") - result.serverInfo["certificate"]["validFrom"] = context.external.get("cert_valid_from") - result.serverInfo["certificate"]["validTo"] = context.external.get("cert_expiry") + result.serverInfo["certificate"]["valid"] = context.certificate.get("valid") + result.serverInfo["certificate"]["issuer"] = context.certificate.get("issuer") + result.serverInfo["certificate"]["validFrom"] = context.certificate.get("validFrom") + result.serverInfo["certificate"]["validTo"] = context.certificate.get("validTo") result.score = int(context.scoring.get("score", 0) or 0) result.riskLevel = context.scoring.get("risk_level") or "SAFE" @@ -101,6 +102,7 @@ def build_JSON(result: AnalysisResult) -> dict: "originalUrl": result.originalUrl, "https": result.https, "shortUrl": result.shortUrl, + "threats": result.threats, "ml": result.ml, "externalApi": result.externalApi, "internalDb": result.internalDb, diff --git a/services/scoring.py b/services/scoring.py index ab8e5ad..0eadccf 100644 --- a/services/scoring.py +++ b/services/scoring.py @@ -86,8 +86,7 @@ def score_internal(context: AnalysisContext) -> tuple[int, list]: def score_ml(context: AnalysisContext) -> int: - ml_result = context.detection.get("ml", {}) - score = ml_result.get("score", 0) + score = context.ml.get("score") try: score = int(round(score)) @@ -124,7 +123,7 @@ def score_external(context: AnalysisContext) -> tuple[int, list]: score += 20 reasons.append("block_count_high") - cert_valid = external.get("cert_valid_yn") + cert_valid = context.certificate.get("valid") if cert_valid is False: score += 25 reasons.append("invalid_certificate") @@ -166,7 +165,7 @@ def collect_matched_rules(context: AnalysisContext) -> list: safe_browsing = context.external.get("safe_browsing", {}) matched.extend(safe_browsing.get("threat_labels", [])) - ml_result = context.detection.get("ml", {}) + ml_result = context.ml or {} matched.extend(ml_result.get("threats", [])) # 중복 제거 From 6d0a50648704ed0f02d3fd9912c84bc6fb7d11ac Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 24 Apr 2026 11:06:15 +0900 Subject: [PATCH 42/77] Update req_server.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 데이터가 context에 들어가지 않고 base 내에서 없는 키를 찾고 있었음 --- analyzers/req_server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/analyzers/req_server.py b/analyzers/req_server.py index 21bf060..bdb9772 100644 --- a/analyzers/req_server.py +++ b/analyzers/req_server.py @@ -100,7 +100,7 @@ def build_server_result(parsed: dict) -> dict: "error": [] } - base["certificate"].update(parsed) + base.update(parsed) return base @@ -120,4 +120,4 @@ def parse_issuer(issuer_tuple) -> str | None: if flat.get("commonName"): return flat["commonName"] - return None \ No newline at end of file + return None From 81c9791946a82fa8c4f9047aee4e33907e87ce7f Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 24 Apr 2026 11:13:15 +0900 Subject: [PATCH 43/77] Update result.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit json형태 수정 --- models/result.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/models/result.py b/models/result.py index db9ccbf..77be96c 100644 --- a/models/result.py +++ b/models/result.py @@ -50,9 +50,11 @@ class AnalysisResult: "valid": False, "issuer": None, "validFrom": None, - "validTo": None + "validTo": None, + "status_type": None, + "error": [] } }) score: int = 0 - riskLevel: str = "SAFE" \ No newline at end of file + riskLevel: str = "SAFE" From 6d689044a9678752aac8f4398a6f9611a662cc48 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 24 Apr 2026 11:30:57 +0900 Subject: [PATCH 44/77] Update result.py --- models/result.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/models/result.py b/models/result.py index 77be96c..febdb14 100644 --- a/models/result.py +++ b/models/result.py @@ -50,9 +50,7 @@ class AnalysisResult: "valid": False, "issuer": None, "validFrom": None, - "validTo": None, - "status_type": None, - "error": [] + "validTo": None } }) From 3b26b9249bf885d5bab5feeb0beeb5754347abb8 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sat, 25 Apr 2026 23:18:28 +0900 Subject: [PATCH 45/77] =?UTF-8?q?null=EB=B0=98=ED=99=98=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=A0=95=EB=8F=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/ai_analyze.py | 2 +- analyzers/parameter_rule.py | 6 +-- analyzers/redirect_tracker.py | 5 ++- analyzers/req_db.py | 75 ----------------------------------- analyzers/req_server.py | 3 +- core/context.py | 7 ---- core/pipeline.py | 71 +++++---------------------------- main.py | 4 +- models/result.py | 6 --- services/builder.py | 4 +- services/scoring.py | 2 +- services/url_utils.py | 2 +- 12 files changed, 25 insertions(+), 162 deletions(-) delete mode 100644 analyzers/req_db.py diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py index 7cbc4bc..b1897fb 100644 --- a/analyzers/ai_analyze.py +++ b/analyzers/ai_analyze.py @@ -6,7 +6,7 @@ # [수정] ML 입력 URL 결정 함수 추가 def resolve_ml_target_url(context: AnalysisContext) -> str | None: - if context.flags.get("is_shortened_url"): + if context.detection["shortened_url"]["detected"] == True: return context.redirect.get("final_url") or context.url.get("normalized_url") if context.redirect.get("was_checked") and context.redirect.get("final_url"): diff --git a/analyzers/parameter_rule.py b/analyzers/parameter_rule.py index b865ace..757007d 100644 --- a/analyzers/parameter_rule.py +++ b/analyzers/parameter_rule.py @@ -85,8 +85,8 @@ def analyze_query(query: str, result: dict) -> None: "https://" in lower_value or "www." in lower_value ): - if decoded_value not in result["embedded_urls_in_query"]: - result["embedded_urls_in_query"].append(decoded_value) + if decoded_value not in result["embedded_urls"]: + result["embedded_urls"].append(decoded_value) if FLAG_EMBEDDED_URL_IN_QUERY not in result["flags"]: result["flags"].append(FLAG_EMBEDDED_URL_IN_QUERY) @@ -132,7 +132,7 @@ def build_rule_based_result() -> dict: return { "flags": [], "suspicious_query_params": [], - "embedded_urls_in_query": [], + "embedded_urls": [], "suspicious_keywords": { "path": [], "query": [], diff --git a/analyzers/redirect_tracker.py b/analyzers/redirect_tracker.py index 028770f..e713c7b 100644 --- a/analyzers/redirect_tracker.py +++ b/analyzers/redirect_tracker.py @@ -20,10 +20,13 @@ def redirect_tracker(context: AnalysisContext) -> dict: # 단축 URL : 단축 URL을 시작 URL로 함 # 일반 URL : 정규화를 거친 URL을 시작 URL로 함 - if context.flags.get("is_shortened_url"): + if context.detection["shortened_url"]["detected"] is True: start_url = context.request.get("original_url") else: start_url = context.url.get("normalized_url") + + if start_url and not start_url.startswith(("http://", "https://")): + start_url = "https://" + start_url current_url = start_url visited_urls = set() diff --git a/analyzers/req_db.py b/analyzers/req_db.py deleted file mode 100644 index 6cedf59..0000000 --- a/analyzers/req_db.py +++ /dev/null @@ -1,75 +0,0 @@ -import os -import pymysql -from core.context import AnalysisContext - -db_password = os.getenv("MYSQL_ROOT_PASSWORD") - - -def req_db(context: AnalysisContext) -> dict: - url = context.url.get("normalized_url") - - try: - row = select_url_record(url) - except pymysql.MySQLError as e: # [수정] - return build_db_result( - exists=False, - data=None, - status="db_error", - error=str(e) - ) - except Exception as e: # [수정] - return build_db_result( - exists=False, - data=None, - status="unknown_error", - error=str(e) - ) - - if row: - return build_db_result( - exists=True, - data=row, - status="success" # [수정] - ) - - return build_db_result( - exists=False, - data=None, - status="success" # [수정] - ) - - -def select_url_record(url: str | None): - if not url: - return None - - conn = pymysql.connect( - host="veriq-db", - user="root", - password=db_password, - database="veriq_db", - port=3306, - charset="utf8mb4", - cursorclass=pymysql.cursors.DictCursor, - ) - - try: - with conn.cursor() as cursor: - cursor.execute( - "SELECT * FROM url_analysis WHERE final_url = %s LIMIT 1", - (url,) - ) - return cursor.fetchone() - finally: - conn.close() - - -def build_db_result(**overrides) -> dict: - result = { - "exists": False, - "data": None, - "status": None, # [수정] - "error": None # [수정] - } - result.update(overrides) - return result \ No newline at end of file diff --git a/analyzers/req_server.py b/analyzers/req_server.py index bdb9772..c6edaa8 100644 --- a/analyzers/req_server.py +++ b/analyzers/req_server.py @@ -66,13 +66,14 @@ def parse_cert_result(raw: dict) -> dict: rtype = raw.get("type") if rtype != STATUS_SUCCESS: + error = raw.get("error") return { "valid": False, "issuer": None, "validFrom": None, "validTo": None, "status_type": [rtype], - "error": raw.get("error") + "error": [error] } cert = raw.get("data") or {} diff --git a/core/context.py b/core/context.py index 0c4e0c6..53a02b5 100644 --- a/core/context.py +++ b/core/context.py @@ -32,9 +32,6 @@ class AnalysisContext: certificate: Dict[str, Any] = field(default_factory=dict) - # [수정] DB 조회 결과 필드 추가 - report_exist: Dict[str, Any] = field(default_factory=dict) - # 내부 실행 상태, 경고, 에러, 완료 단계 등 저장 meta: Dict[str, Any] = field(default_factory=dict) @@ -121,10 +118,6 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex "status_type": None, "error": [] }, - report_exist={ - "exists": False, - "data": None - }, meta={ "status": "initialized", "completed_steps": [], diff --git a/core/pipeline.py b/core/pipeline.py index d00fdf5..fbb5c2f 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -7,7 +7,6 @@ url_shorten_checker as short, ai_analyze as ai, req_api as api, - req_db as db, req_server as server ) from services import builder, scoring, url_utils as utils @@ -29,7 +28,7 @@ def _build_final_json(context) -> dict: def run_pipeline(context): _send_progress(context, "TEST", "IN_PROGRESS", "파이프라인 진입 성공") - # [수정] 단축 URL 검사 전에 hostname만 먼저 확보 + # 단축 URL 검사 전에 hostname만 먼저 확보 raw_url = context.request.get("original_url") cleaned_url = utils.sanitize_input_url(raw_url) parsed_result = utils.parse_url_components(cleaned_url) @@ -43,67 +42,17 @@ def run_pipeline(context): _send_progress(context, "SHORT_URL_CHECK", "IN_PROGRESS", "단축 URL 여부를 확인하고 있습니다.") short_result = short.url_shorten_checker(context) context.detection["shortened_url"] = short_result - context.flags["is_shortened_url"] = short_result.get("detected", False) _send_progress(context, "SHORT_URL_CHECK", "COMPLETED", "단축 URL 여부 확인이 완료되었습니다.") - if context.flags["is_shortened_url"]: - return run_shortened_pipeline(context) - else: - return run_normal_pipeline(context) + if context.detection["shortened_url"].get("detected", False): + _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 경로를 추적하고 있습니다.") + context.redirect = track.redirect_tracker(context) + _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 추적이 완료되었습니다.") - -def run_shortened_pipeline(context): - _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 경로를 추적하고 있습니다.") - context.redirect = track.redirect_tracker(context) - _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 추적이 완료되었습니다.") - - _send_progress(context, "URL_NORMALIZE", "IN_PROGRESS", "최종 URL을 정규화하고 있습니다.") - utils.normalize_url(context) - _send_progress(context, "URL_NORMALIZE", "COMPLETED", "최종 URL 정규화가 완료되었습니다.") - - _send_progress(context, "DB_CHECK", "IN_PROGRESS", "내부 DB에서 기존 분석 이력을 조회하고 있습니다.") - context.report_exist = db.req_db(context) - _send_progress(context, "DB_CHECK", "COMPLETED", "내부 DB 조회가 완료되었습니다.") - - if context.report_exist.get("exists"): - return context.report_exist.get("data") - - _send_progress(context, "API_REQ", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") - context.external = api.req_api(context) - context.certificate = server.req_server(context) - _send_progress(context, "API_REQ", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") - - safe_browsing = context.external.get("safe_browsing", {}) - if safe_browsing.get("is_threat"): - return _build_final_json(context) - - _send_progress(context, "RULE_ANALYSIS", "IN_PROGRESS", "도메인 및 파라미터 규칙 분석을 진행하고 있습니다.") - context.detection["rule_based"] = rule.parameter_rule(context) - _send_progress(context, "RULE_ANALYSIS", "COMPLETED", "도메인 및 파라미터 규칙 분석이 완료되었습니다.") - - _send_progress(context, "ML_ANALYSIS", "IN_PROGRESS", "ML 기반 위험 분석을 진행하고 있습니다.") - context.detection["ml"] = ai.ai_analyze(context) - _send_progress(context, "ML_ANALYSIS", "COMPLETED", "ML 기반 위험 분석이 완료되었습니다.") - - _send_progress(context, "SCORING", "IN_PROGRESS", "최종 위험도를 계산하고 있습니다.") - context.scoring = scoring.score_analysis(context) - _send_progress(context, "SCORING", "COMPLETED", "최종 위험도 계산이 완료되었습니다.") - - return _build_final_json(context) - - -def run_normal_pipeline(context): _send_progress(context, "URL_NORMALIZE", "IN_PROGRESS", "URL을 정규화하고 있습니다.") utils.normalize_url(context) _send_progress(context, "URL_NORMALIZE", "COMPLETED", "URL 정규화가 완료되었습니다.") - _send_progress(context, "DB_CHECK", "IN_PROGRESS", "내부 DB에서 기존 분석 이력을 조회하고 있습니다.") - context.report_exist = db.req_db(context) - _send_progress(context, "DB_CHECK", "COMPLETED", "내부 DB 조회가 완료되었습니다.") - - if context.report_exist.get("exists"): - return context.report_exist.get("data") - _send_progress(context, "API_REQ", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") context.external = api.req_api(context) context.certificate = server.req_server(context) @@ -111,6 +60,7 @@ def run_normal_pipeline(context): safe_browsing = context.external.get("safe_browsing", {}) if safe_browsing.get("is_threat"): + context.scoring = scoring.score_analysis(context) return _build_final_json(context) _send_progress(context, "RULE_ANALYSIS", "IN_PROGRESS", "도메인 및 파라미터 규칙 분석을 진행하고 있습니다.") @@ -118,12 +68,13 @@ def run_normal_pipeline(context): _send_progress(context, "RULE_ANALYSIS", "COMPLETED", "도메인 및 파라미터 규칙 분석이 완료되었습니다.") _send_progress(context, "ML_ANALYSIS", "IN_PROGRESS", "ML 기반 위험 분석을 진행하고 있습니다.") - context.detection["ml"] = ai.ai_analyze(context) + context.ml = ai.ai_analyze(context) _send_progress(context, "ML_ANALYSIS", "COMPLETED", "ML 기반 위험 분석이 완료되었습니다.") - _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 여부를 확인하고 있습니다.") - context.redirect = track.redirect_tracker(context) - _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 확인이 완료되었습니다.") + if not context.detection.get("shortened_url", {}).get("detected", False): + _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 경로를 추적하고 있습니다.") + context.redirect = track.redirect_tracker(context) + _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 추적이 완료되었습니다.") _send_progress(context, "SCORING", "IN_PROGRESS", "최종 위험도를 계산하고 있습니다.") context.scoring = scoring.score_analysis(context) diff --git a/main.py b/main.py index ffd6f22..fd638e6 100644 --- a/main.py +++ b/main.py @@ -26,10 +26,8 @@ def analyze(req: AnalysisRequest): print("start pipeline") result = run_pipeline(context) - # [추가] 최종 결과 callback 전송 print("posting final result...") guest_Uuid = context.request.get("guest_Uuid") callback.send_final_result(guest_Uuid, result) - # [추가] 로컬 확인용으로 callback 상태 포함 - return print("analyze done") + return {"message": "analyze done", "result": result} diff --git a/models/result.py b/models/result.py index febdb14..f3ad017 100644 --- a/models/result.py +++ b/models/result.py @@ -29,12 +29,6 @@ class AnalysisResult: "result": None }) - internalDb: dict = field(default_factory=lambda: { - "exists": False, - "reportCount": None, - "blockCount": None - }) - redirect: dict = field(default_factory=lambda: { "finalUrl": None, "redirectCount": 0 diff --git a/services/builder.py b/services/builder.py index cba4885..1aa26f1 100644 --- a/services/builder.py +++ b/services/builder.py @@ -57,8 +57,7 @@ def build_result(context: AnalysisContext) -> AnalysisResult: result.https["isSecure"] = bool(context.url.get("is_https", False)) result.shortUrl["isShortened"] = bool( - context.flags.get("is_shortened_url", False) - or context.detection.get("shortened_url", {}).get("detected", False) + context.detection.get("shortened_url", {}).get("detected", False) ) result.threats = build_threats(context) @@ -105,7 +104,6 @@ def build_JSON(result: AnalysisResult) -> dict: "threats": result.threats, "ml": result.ml, "externalApi": result.externalApi, - "internalDb": result.internalDb, "redirect": result.redirect, "serverInfo": result.serverInfo, "score": result.score, diff --git a/services/scoring.py b/services/scoring.py index 0eadccf..34de34a 100644 --- a/services/scoring.py +++ b/services/scoring.py @@ -70,7 +70,7 @@ def score_internal(context: AnalysisContext) -> tuple[int, list]: flag_scores = { "suspicious_query_param_detected": 12, - "embedded_url_in_query": 15, + "embedded_url": 15, "suspicious_query_keyword_detected": 8, "suspicious_path_keyword_detected": 8, "suspicious_fragment_keyword_detected": 8, diff --git a/services/url_utils.py b/services/url_utils.py index e41532d..d36c5b7 100644 --- a/services/url_utils.py +++ b/services/url_utils.py @@ -110,7 +110,7 @@ def rebuild_normalized_url(parsed_result: dict, normalized_scheme: str, normaliz # context.url에 정규화 결과를 기록하는 메인 함수 def normalize_url(context: AnalysisContext) -> None: # 단축 URL이면 리다이렉트 후 최종 URL을 사용, 아니면 원본 URL 사용 - if context.flags.get("is_shortened_url"): + if context.detection["shortened_url"]["detected"]: target_url = context.redirect.get("final_url") else: target_url = context.request.get("original_url") From 694835c77ccd6b0b3b826bc7bc7e42c7098ae6a8 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sun, 26 Apr 2026 17:21:18 +0900 Subject: [PATCH 46/77] =?UTF-8?q?builder=EC=9E=94=EC=97=AC=20db=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/builder.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/services/builder.py b/services/builder.py index 1aa26f1..b0ec5db 100644 --- a/services/builder.py +++ b/services/builder.py @@ -46,7 +46,6 @@ def build_external_result(context: AnalysisContext) -> str | None: def build_result(context: AnalysisContext) -> AnalysisResult: safe_browsing = context.external.get("safe_browsing", {}) - report_data = context.report_exist.get("data") or {} result = AnalysisResult( guestUuid = context.request.get("guest_Uuid", {}), @@ -70,10 +69,6 @@ def build_result(context: AnalysisContext) -> AnalysisResult: result.externalApi["provider"] = "Google Safe Browsing" result.externalApi["result"] = build_external_result(context) - result.internalDb["exists"] = bool(context.report_exist.get("exists", False)) - result.internalDb["reportCount"] = report_data.get("report_count") - result.internalDb["blockCount"] = report_data.get("block_count") - result.redirect["finalUrl"] = ( context.redirect.get("final_url") or context.url.get("normalized_url") From f5f81f53b78b9555a9b7aa8a5171dce9b37ac60a Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Tue, 28 Apr 2026 22:39:47 +0900 Subject: [PATCH 47/77] =?UTF-8?q?*=EC=9D=BC=EB=B6=80=20=EB=AF=B8=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EB=B3=80=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/redirect_tracker.py | 7 ---- core/context.py | 64 +++++++++++------------------------ services/url_utils.py | 1 - 3 files changed, 19 insertions(+), 53 deletions(-) diff --git a/analyzers/redirect_tracker.py b/analyzers/redirect_tracker.py index e713c7b..906efbd 100644 --- a/analyzers/redirect_tracker.py +++ b/analyzers/redirect_tracker.py @@ -48,7 +48,6 @@ def redirect_tracker(context: AnalysisContext) -> dict: redirect_chain=redirect_chain, loop_detected=True, status_type=[None, STATUS_LOOP_DETECTED], - result_type=False ) # 방문했던 URL 리스트에 현재 URL 추가 @@ -78,7 +77,6 @@ def redirect_tracker(context: AnalysisContext) -> dict: final_url=current_url, redirect_chain=redirect_chain, status_type=[status_code, STATUS_INVALID_LOCATION], - result_type=False ) redirect_count += 1 @@ -89,7 +87,6 @@ def redirect_tracker(context: AnalysisContext) -> dict: final_url=current_url, redirect_chain=redirect_chain, status_type=[status_code, STATUS_TOO_MANY_REDIRECTS], - result_type=False ) current_url = next_url @@ -102,7 +99,6 @@ def redirect_tracker(context: AnalysisContext) -> dict: final_url=current_url, redirect_chain=redirect_chain, status_type=[status_code, STATUS_SUCCESS], - result_type=True ) # 클라이언트 에러 종료 @@ -112,7 +108,6 @@ def redirect_tracker(context: AnalysisContext) -> dict: final_url=current_url, redirect_chain=redirect_chain, status_type=[status_code, STATUS_CLIENT_ERROR], - result_type=False ) # 요청 실패 / 기타 종료 @@ -121,7 +116,6 @@ def redirect_tracker(context: AnalysisContext) -> dict: final_url=current_url, redirect_chain=redirect_chain, status_type=[status_code, STATUS_REQUEST_FAILED], - result_type=False ) @@ -185,7 +179,6 @@ def build_redirect_result(**overrides) -> dict: "redirect_chain": [], "loop_detected": False, "status_type": [None, None], - "result_type": None } for key in overrides: diff --git a/core/context.py b/core/context.py index 53a02b5..2f29e94 100644 --- a/core/context.py +++ b/core/context.py @@ -3,49 +3,31 @@ # 검사 요청 수신 시, 반드시 create_initial_context(scan_id, original_url)를 호출해 context를 생성합니다. from dataclasses import dataclass, field from typing import Any, Dict - +# flags 삭제, info에 단계 및 기타 정보 일괄 저장하는 걸로. +https: dict = field(default_factory=lambda: { + "isSecure": False + }) @dataclass -class AnalysisContext: - # 수신한 요청 정보 - request: Dict[str, Any] = field(default_factory=dict) - - # 정규화된 URL 정보 및 검사용 URL 원문 - url: Dict[str, Any] = field(default_factory=dict) - - # 리다이렉트 추적 결과 - redirect: Dict[str, Any] = field(default_factory=dict) - - # 룰 기반 검사기를 거친 결과 - detection: Dict[str, Any] = field(default_factory=dict) - - ml: Dict[str, Any] = field(default_factory=dict) - - # flags : 판정 및 로직 분기 판별에서 사용하기 위한 플래그 - flags: Dict[str, Any] = field(default_factory=dict) - - # 스코어링 내용 및 결과 점수 - scoring: Dict[str, Any] = field(default_factory=dict) - - # 외부 시스템 연동 결과용 묶음 - external: Dict[str, Any] = field(default_factory=dict) - - certificate: Dict[str, Any] = field(default_factory=dict) - - # 내부 실행 상태, 경고, 에러, 완료 단계 등 저장 - meta: Dict[str, Any] = field(default_factory=dict) +class AnalysisContext: # key : gusetUuid, originalURL (request에만 저장됨) + request: Dict[str, Any] = field(default_factory=dict) # 수신한 요청 + url: Dict[str, Any] = field(default_factory=dict) # 정규화 거친 url + redirect: Dict[str, Any] = field(default_factory=dict) # 리다이렉트 추적 결과 + detection: Dict[str, Any] = field(default_factory=dict) # 룰 기반 검사기+단축 url + ml: Dict[str, Any] = field(default_factory=dict) # ml검사결과+ml점수 + external: Dict[str, Any] = field(default_factory=dict) # safe browsing/whois api + certificate: Dict[str, Any] = field(default_factory=dict) # 인증서 조회 + scoring: Dict[str, Any] = field(default_factory=dict) # 스코어링 결과 저장 + meta: Dict[str, Any] = field(default_factory=dict) # 실행 단계, 내부 errors 또는 기타 info def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContext: return AnalysisContext( request={ - "guest_Uuid": guest_Uuid, # [추가] - "request_id": guest_Uuid, # [추가] 기존 builder 호환용 - "original_url": original_url, + "guest_Uuid": guest_Uuid, + "original_url": original_url }, url={ - "original_url": original_url, - "sanitized_url": None, "normalized_url": None, "scheme": None, "hostname": None, @@ -54,17 +36,16 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex "query": None, "fragment": None, "is_https": False, - "has_explicit_scheme": False, + "has_explicit_scheme": False, #-> 좀 더 쉽게 변경예정 "is_default_port": False, }, redirect={ - "was_checked": False, "redirect_count": 0, "final_url": None, "redirect_chain": [], "loop_detected": False, - "status_type": None, - "result_type": None, + "status_type": [], #status code, 상태 상수 + "result": str # str로 success/fail 저장 }, detection={ "shortened_url": { @@ -86,13 +67,6 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex "score": None, "threats": [] }, - flags={ - "normalized": False, - "is_shortened_url": False, - "redirect_checked": False, - "has_suspicious": False, - "suspicious_details": [] - }, scoring={ "score": None, "risk_level": None, diff --git a/services/url_utils.py b/services/url_utils.py index d36c5b7..b939fa1 100644 --- a/services/url_utils.py +++ b/services/url_utils.py @@ -129,7 +129,6 @@ def normalize_url(context: AnalysisContext) -> None: ) # context.url에 최종 결과 기록 - context.url["sanitized_url"] = cleaned_url context.url["normalized_url"] = rebuilt_result["normalized_url"] context.url["scheme"] = scheme_result["normalized_scheme"] context.url["hostname"] = normalized_comp["normalized_hostname"] From ff8bfefa2537370d2568af2990994f501bfcda8f Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Wed, 29 Apr 2026 10:07:48 +0900 Subject: [PATCH 48/77] =?UTF-8?q?req=5Fapi=EC=97=90=20whois,=20otx=20api?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80.=20=EC=9D=B4=20commit=EC=9D=80=20otx=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9E=85=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot --- analyzers/api_test.py | 35 +++++++++++++++++ analyzers/req_api.py | 87 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 analyzers/api_test.py diff --git a/analyzers/api_test.py b/analyzers/api_test.py new file mode 100644 index 0000000..b2b50b1 --- /dev/null +++ b/analyzers/api_test.py @@ -0,0 +1,35 @@ +# req_api 테스트 코드 +from req_api import req_api # 제공해주신 코드가 담긴 파일명으로 변경하세요. + +# 1. AnalysisContext를 흉내 내는 Mock 클래스 생성 +class MockContext: + def __init__(self, url: str): + # 원본 코드에서 context.url.get("normalized_url") 형태로 접근하므로 딕셔너리로 만듭니다. + self.url = {"normalized_url": url} + +def run_test(): + # 테스트용 URL 리스트 + test_urls = [ + # 1. 정상 URL 테스트 + "https://www.google.com", + + # 2. 악성 URL 테스트 (Google이 제공하는 Safe Browsing 테스트용 안전한 악성 URL) + " https://www.newerame.com" + ] + + for target_url in test_urls: + print(f"\n[테스트 시작] URL: {target_url}") + + # Mock Context 객체 생성 + context = MockContext(target_url) + + # 핵심 함수 호출 + result = req_api(context) + + # 결과 출력 + print("결과:") + import json + print(json.dumps(result, indent=4, ensure_ascii=False)) + +if __name__ == "__main__": + run_test() \ No newline at end of file diff --git a/analyzers/req_api.py b/analyzers/req_api.py index fc0ba28..c139e64 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -1,8 +1,12 @@ # 외부 api 조회 -from dotenv import load_dotenv import os -import requests import time +import whois +import tldextract +import requests +from datetime import datetime +from OTXv2 import OTXv2, IndicatorTypes +from dotenv import load_dotenv from core.context import AnalysisContext @@ -10,6 +14,7 @@ GSB_CODE = "GSB" GSB_API_KEY = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY") +OTX_API_KEY = os.getenv("OTX_API_KEY") STATUS_SUCCESS = "success" STATUS_TIMEOUT = "timeout" @@ -18,14 +23,82 @@ STATUS_CLIENT_ERROR = "client_error" STATUS_MISSING_API_KEY = "missing_api_key" # [수정] +def get_domain_age(url: str) -> str | None: + """WHOIS를 통해 도메인 생성 일자 조회""" + try: + ext = tldextract.extract(url) + domain = f"{ext.domain}.{ext.suffix}" + domain_info = whois.whois(domain) + + creation_date = domain_info.creation_date + if isinstance(creation_date, list): + creation_date = creation_date[0] + + if isinstance(creation_date, datetime): + return creation_date.strftime("%Y-%m-%d") + return None + except Exception: + return None + +def get_otx_report_count(url: str) -> int: + """AlienVault OTX를 통해 해당 URL이 포함된 Pulse(신고) 횟수 조회""" + if not OTX_API_KEY: + return 0 + + try: + otx = OTXv2(OTX_API_KEY) + # URL 지표에 대한 일반 정보 조회 + result = otx.get_indicator_details_full(IndicatorTypes.URL, url) + + # 'general' 섹션 내의 'pulse_info'에서 'count'가 누적 신고 횟수임 + report_count = result.get("general", {}).get("pulse_info", {}).get("count", 0) + return report_count + except Exception as e: + print(f"OTX 조회 에러: {e}") + return 0 + def req_api(context: AnalysisContext) -> dict: target_url = context.url.get("normalized_url") - - raw = GSB_req_api(target_url) - parsed = parse_GSB_result(raw) - - return build_api_result("safe_browsing", parsed) + + # 1차 조회: Google Safe Browsing + raw_gsb = GSB_req_api(target_url) + parsed_result = parse_GSB_result(raw_gsb) + + report_count = 0 + # 2차 조회: OTX 상세 조회 (사용자 요청 로직) + report_count = get_otx_report_count(target_url) + + # --- [교차 검증 및 라벨 누적 로직 추가] --- + + # OTX 신고 이력이 1개라도 있다면 위협으로 간주 + if report_count > 0: + parsed_result["is_threat"] = True + # 기존 GSB 라벨이 있을 수 있으므로 리스트에 추가 (중복 방지 위해 set 사용 고려 가능) + if "OTX:MALWARE" not in parsed_result["threat_labels"]: + parsed_result["threat_labels"].append("OTX:MALWARE") + + # 3차 조회: 도메인 연령 조회 + domain_age = get_domain_age(target_url) + + # 결과 조립 + return build_api_result_v2("safe_browsing", parsed_result, report_count, domain_age) + +def build_api_result_v2(name: str, parsed: dict, report_count: int, domain_age: str | None) -> dict: + base = { + name: { + "is_threat": False, + "status_type": None, + "result_type": None, + "threat_labels": [] + }, + "report_count": report_count, + "block_count": 0, # 내부 DB 차단 데이터가 없다면 0으로 유지 + "domain_age": domain_age + } + + base[name].update(parsed) + return base def GSB_req_api(url: str | None) -> dict: From 90156df59d9160f89722f44ec3aaee989eed157b Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Wed, 29 Apr 2026 10:31:48 +0900 Subject: [PATCH 49/77] =?UTF-8?q?otx=20api=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20gsb=20api=20=EC=B2=98=EB=9F=BC=20requests=EB=A1=9C?= =?UTF-8?q?=20=EC=99=B8=EB=B6=80=EB=A1=9C=20=EB=B9=BC=EB=82=B8=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/api_test.py | 2 +- analyzers/req_api.py | 47 +++++++++++++++++++++++++++++++++---------- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/analyzers/api_test.py b/analyzers/api_test.py index b2b50b1..2dde98a 100644 --- a/analyzers/api_test.py +++ b/analyzers/api_test.py @@ -14,7 +14,7 @@ def run_test(): "https://www.google.com", # 2. 악성 URL 테스트 (Google이 제공하는 Safe Browsing 테스트용 안전한 악성 URL) - " https://www.newerame.com" + "https://ey43.com/4/10379548" ] for target_url in test_urls: diff --git a/analyzers/req_api.py b/analyzers/req_api.py index c139e64..901982c 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -4,8 +4,10 @@ import whois import tldextract import requests +from urllib.parse import quote + from datetime import datetime -from OTXv2 import OTXv2, IndicatorTypes + from dotenv import load_dotenv from core.context import AnalysisContext @@ -39,22 +41,45 @@ def get_domain_age(url: str) -> str | None: return None except Exception: return None - + def get_otx_report_count(url: str) -> int: - """AlienVault OTX를 통해 해당 URL이 포함된 Pulse(신고) 횟수 조회""" + """OTX를 통해 pulse 내역(신고 횟수) 조회""" + if not url: + return 0 if not OTX_API_KEY: return 0 + + # 1. URL 인코딩: http://... -> http%3A%2F%2F... 형태로 안전하게 변환 + encoded_url = quote(url, safe='') + + # 2. 인코딩된 URL을 엔드포인트에 삽입 + endpoint = f"https://otx.alienvault.com/api/v1/indicators/url/{encoded_url}/general" + headers = { + "X-OTX-API-KEY": OTX_API_KEY + } + + start = time.time() + try: - otx = OTXv2(OTX_API_KEY) - # URL 지표에 대한 일반 정보 조회 - result = otx.get_indicator_details_full(IndicatorTypes.URL, url) + response = requests.get(endpoint, headers=headers, timeout=30) - # 'general' 섹션 내의 'pulse_info'에서 'count'가 누적 신고 횟수임 - report_count = result.get("general", {}).get("pulse_info", {}).get("count", 0) - return report_count - except Exception as e: - print(f"OTX 조회 에러: {e}") + # [디버깅용 팁] 만약 또 0이 나온다면 아래 주석을 풀고 상태 코드를 확인해 보세요. + # print(f"OTX 상태 코드: {response.status_code}") + + try: + response_data = response.json() if response.status_code == 200 else None + except ValueError: + response_data = None + + if response_data: + return response_data.get("pulse_info", {}).get("count", 0) + + return 0 + + except requests.exceptions.Timeout: + return 0 + except requests.exceptions.RequestException: return 0 From 105de773566e642e820ed98073ca792f28309746 Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Wed, 29 Apr 2026 11:39:50 +0900 Subject: [PATCH 50/77] =?UTF-8?q?otx=EC=97=90=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EB=90=9C=20=EC=95=85=EC=84=B1=20url=EC=9D=B4=20=EC=A0=95?= =?UTF-8?q?=EC=83=81=20url=20ping=20=ED=98=B8=EC=B6=9C=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=EC=9D=84=20=ED=8F=AC=ED=95=A8=ED=95=A0=20=EA=B2=BD?= =?UTF-8?q?=EC=9A=B0,=20=EC=A0=95=EC=83=81=20url=EC=9D=B4=20=EC=95=85?= =?UTF-8?q?=EC=84=B1=20url=EB=A1=9C=20=EC=98=A4=ED=83=90=EB=90=98=EB=8A=94?= =?UTF-8?q?=20=EA=B2=BD=EC=9A=B0=20=EB=B0=9C=EC=83=9D.=20=EC=9D=B4?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=A9=EC=A7=80=ED=95=98=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=9D=BC=EB=8B=A8=20=ED=99=94=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=98=A4=ED=83=90=EC=9D=84=20=EC=9E=84?= =?UTF-8?q?=EC=9D=98=EB=A1=9C=20=EB=A7=89=EC=95=84=EB=91=94=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9E=85=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_api.py | 64 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/analyzers/req_api.py b/analyzers/req_api.py index 901982c..cca7e43 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -4,12 +4,10 @@ import whois import tldextract import requests -from urllib.parse import quote +from urllib.parse import quote from datetime import datetime - from dotenv import load_dotenv - from core.context import AnalysisContext load_dotenv() @@ -25,6 +23,44 @@ STATUS_CLIENT_ERROR = "client_error" STATUS_MISSING_API_KEY = "missing_api_key" # [수정] +# --- [수정된 화이트리스트 판별 함수] --- +def is_whitelisted(url: str) -> bool: + """URL의 도메인이 화이트리스트에 포함되어 있는지 확인""" + if not url: + return False + + # 기존에 만든 명시적 도메인 화이트리스트 + WHITELIST_DOMAINS = { + "google.com", "google.co.kr", "naver.com", "daum.net", "yahoo.com", "bing.com", + "facebook.com", "instagram.com", "twitter.com", "linkedin.com", "youtube.com", "kakao.com", + "apple.com", "microsoft.com", "github.com", "stackoverflow.com", "cloudflare.com", "amazon.com", "aws.com" + } + + # 새롭게 추가된 안전한 접미사(Suffix) 패턴 리스트 + SAFE_SUFFIXES = { + # 한국 공공/교육/연구/군사 + "go.kr", "ac.kr", "re.kr", "mil.kr", "es.kr", "ms.kr", "hs.kr", "sc.kr", + # 글로벌 공공/교육/국제기구 + "gov", "edu", "mil", "int" + } + + try: + ext = tldextract.extract(url) + domain = f"{ext.domain}.{ext.suffix}" + + # 1. 도메인 완전 일치 확인 (예: google.com) + if domain in WHITELIST_DOMAINS: + return True + + # 2. 안전한 접미사 패턴 확인 (예: .go.kr, .edu 등) + # ext.suffix는 tldextract가 알아서 'go.kr'이나 'edu' 형태로 잘라줍니다. + if ext.suffix in SAFE_SUFFIXES: + return True + + return False + except Exception: + return False + def get_domain_age(url: str) -> str | None: """WHOIS를 통해 도메인 생성 일자 조회""" try: @@ -91,17 +127,21 @@ def req_api(context: AnalysisContext) -> dict: parsed_result = parse_GSB_result(raw_gsb) report_count = 0 - # 2차 조회: OTX 상세 조회 (사용자 요청 로직) - report_count = get_otx_report_count(target_url) + # --- [수정됨] 화이트리스트 대조 로직 --- # --- [교차 검증 및 라벨 누적 로직 추가] --- - - # OTX 신고 이력이 1개라도 있다면 위협으로 간주 - if report_count > 0: - parsed_result["is_threat"] = True - # 기존 GSB 라벨이 있을 수 있으므로 리스트에 추가 (중복 방지 위해 set 사용 고려 가능) - if "OTX:MALWARE" not in parsed_result["threat_labels"]: - parsed_result["threat_labels"].append("OTX:MALWARE") + # 화이트리스트에 없을 때만 OTX 조회를 수행합니다. + if not is_whitelisted(target_url): + # 2차 조회: OTX 상세 조회 + report_count = get_otx_report_count(target_url) + + # OTX 신고 이력이 1개라도 있다면 위협으로 간주 + if report_count > 0: + parsed_result["is_threat"] = True + # 기존 GSB 라벨이 있을 수 있으므로 리스트에 추가 + if "OTX:MALWARE" not in parsed_result["threat_labels"]: + parsed_result["threat_labels"].append("OTX:MALWARE") + # --------------------------------------- # 3차 조회: 도메인 연령 조회 domain_age = get_domain_age(target_url) From 3972d47439f440b792327bfb1229d8eab0e34bac Mon Sep 17 00:00:00 2001 From: kghc4232 Date: Wed, 29 Apr 2026 14:10:17 +0900 Subject: [PATCH 51/77] =?UTF-8?q?req=20->=20build=20->=20parse=20->=20resu?= =?UTF-8?q?lt=20=EA=B5=AC=EC=A1=B0=EA=B0=80=20=EC=97=AC=EB=9F=AC=20api=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EA=B3=BC=EC=A0=95=20=E9=96=93=20=EC=9D=BC?= =?UTF-8?q?=EA=B4=80=EB=90=98=EA=B2=8C=20=EC=9C=A0=EC=A7=80=EB=90=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=BD=94=EB=93=9C=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A7=81=20=EC=9E=91=EC=97=85=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=ED=96=89=ED=95=98=EA=B3=A0=20otx=20api=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=97=90=EB=8F=84=20gsb=EC=9D=98=20=EA=B2=BD=EC=9A=B0?= =?UTF-8?q?=EC=B2=98=EB=9F=BC=20threat=20info=20=EB=9D=BC=EB=B2=A8?= =?UTF-8?q?=EB=A7=81=EC=9D=B4=20=EC=9D=B4=EB=A3=A8=EC=96=B4=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=ED=95=9C=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9E=85=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_api.py | 304 ++++++++++++++++++++----------------------- 1 file changed, 142 insertions(+), 162 deletions(-) diff --git a/analyzers/req_api.py b/analyzers/req_api.py index cca7e43..910f4f9 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -21,26 +21,24 @@ STATUS_REQUEST_FAILED = "request_failed" STATUS_INVALID_INPUT = "invalid_input" STATUS_CLIENT_ERROR = "client_error" -STATUS_MISSING_API_KEY = "missing_api_key" # [수정] +STATUS_MISSING_API_KEY = "missing_api_key" -# --- [수정된 화이트리스트 판별 함수] --- +# ========================================== +# 1. 공통 유틸리티 및 화이트리스트 로직 +# ========================================== def is_whitelisted(url: str) -> bool: """URL의 도메인이 화이트리스트에 포함되어 있는지 확인""" if not url: return False - # 기존에 만든 명시적 도메인 화이트리스트 WHITELIST_DOMAINS = { "google.com", "google.co.kr", "naver.com", "daum.net", "yahoo.com", "bing.com", "facebook.com", "instagram.com", "twitter.com", "linkedin.com", "youtube.com", "kakao.com", "apple.com", "microsoft.com", "github.com", "stackoverflow.com", "cloudflare.com", "amazon.com", "aws.com" } - # 새롭게 추가된 안전한 접미사(Suffix) 패턴 리스트 SAFE_SUFFIXES = { - # 한국 공공/교육/연구/군사 "go.kr", "ac.kr", "re.kr", "mil.kr", "es.kr", "ms.kr", "hs.kr", "sc.kr", - # 글로벌 공공/교육/국제기구 "gov", "edu", "mil", "int" } @@ -48,12 +46,8 @@ def is_whitelisted(url: str) -> bool: ext = tldextract.extract(url) domain = f"{ext.domain}.{ext.suffix}" - # 1. 도메인 완전 일치 확인 (예: google.com) if domain in WHITELIST_DOMAINS: return True - - # 2. 안전한 접미사 패턴 확인 (예: .go.kr, .edu 등) - # ext.suffix는 tldextract가 알아서 'go.kr'이나 'edu' 형태로 잘라줍니다. if ext.suffix in SAFE_SUFFIXES: return True @@ -61,95 +55,42 @@ def is_whitelisted(url: str) -> bool: except Exception: return False -def get_domain_age(url: str) -> str | None: - """WHOIS를 통해 도메인 생성 일자 조회""" - try: - ext = tldextract.extract(url) - domain = f"{ext.domain}.{ext.suffix}" - domain_info = whois.whois(domain) - - creation_date = domain_info.creation_date - if isinstance(creation_date, list): - creation_date = creation_date[0] - - if isinstance(creation_date, datetime): - return creation_date.strftime("%Y-%m-%d") - return None - except Exception: - return None - -def get_otx_report_count(url: str) -> int: - """OTX를 통해 pulse 내역(신고 횟수) 조회""" - if not url: - return 0 - if not OTX_API_KEY: - return 0 - - # 1. URL 인코딩: http://... -> http%3A%2F%2F... 형태로 안전하게 변환 - encoded_url = quote(url, safe='') - - # 2. 인코딩된 URL을 엔드포인트에 삽입 - endpoint = f"https://otx.alienvault.com/api/v1/indicators/url/{encoded_url}/general" - - headers = { - "X-OTX-API-KEY": OTX_API_KEY - } - - start = time.time() - - try: - response = requests.get(endpoint, headers=headers, timeout=30) - - # [디버깅용 팁] 만약 또 0이 나온다면 아래 주석을 풀고 상태 코드를 확인해 보세요. - # print(f"OTX 상태 코드: {response.status_code}") - - try: - response_data = response.json() if response.status_code == 200 else None - except ValueError: - response_data = None - - if response_data: - return response_data.get("pulse_info", {}).get("count", 0) - - return 0 - - except requests.exceptions.Timeout: - return 0 - except requests.exceptions.RequestException: - return 0 - - +# ========================================== +# 2. 메인 오케스트레이션 로직 +# ========================================== def req_api(context: AnalysisContext) -> dict: target_url = context.url.get("normalized_url") - # 1차 조회: Google Safe Browsing + # [1] GSB 파이프라인 raw_gsb = GSB_req_api(target_url) - parsed_result = parse_GSB_result(raw_gsb) + parsed_gsb_result = parse_GSB_result(raw_gsb) + # [2] OTX 파이프라인 report_count = 0 - - # --- [수정됨] 화이트리스트 대조 로직 --- - # --- [교차 검증 및 라벨 누적 로직 추가] --- - # 화이트리스트에 없을 때만 OTX 조회를 수행합니다. if not is_whitelisted(target_url): - # 2차 조회: OTX 상세 조회 - report_count = get_otx_report_count(target_url) + raw_otx = OTX_req_api(target_url) + + # [수정] count와 동적으로 생성된 라벨 리스트를 함께 받아옵니다. + report_count, otx_labels = parse_OTX_result(raw_otx) - # OTX 신고 이력이 1개라도 있다면 위협으로 간주 if report_count > 0: - parsed_result["is_threat"] = True - # 기존 GSB 라벨이 있을 수 있으므로 리스트에 추가 - if "OTX:MALWARE" not in parsed_result["threat_labels"]: - parsed_result["threat_labels"].append("OTX:MALWARE") - # --------------------------------------- + parsed_gsb_result["is_threat"] = True + + # [수정] 무조건 "OTX:MALWARE"를 넣는 대신, 추출해 낸 라벨들을 순회하며 추가 + for label in otx_labels: + if label not in parsed_gsb_result["threat_labels"]: + parsed_gsb_result["threat_labels"].append(label) - # 3차 조회: 도메인 연령 조회 - domain_age = get_domain_age(target_url) + # [3] WHOIS 파이프라인 + raw_whois = WHOIS_req_api(target_url) + domain_age = parse_WHOIS_result(raw_whois) - # 결과 조립 - return build_api_result_v2("safe_browsing", parsed_result, report_count, domain_age) + # [4] 최종 조립 + return build_api_result("safe_browsing", parsed_gsb_result, report_count, domain_age) + -def build_api_result_v2(name: str, parsed: dict, report_count: int, domain_age: str | None) -> dict: +def build_api_result(name: str, parsed_gsb: dict, report_count: int, domain_age: str | None) -> dict: + """모든 파이프라인의 결과를 모아 최종 포맷으로 조립 (기존 구조 복원)""" base = { name: { "is_threat": False, @@ -158,35 +99,31 @@ def build_api_result_v2(name: str, parsed: dict, report_count: int, domain_age: "threat_labels": [] }, "report_count": report_count, - "block_count": 0, # 내부 DB 차단 데이터가 없다면 0으로 유지 + "block_count": 0, # 내부 DB 미사용 시 0 "domain_age": domain_age } - - base[name].update(parsed) - return base + base[name].update(parsed_gsb) + return base +# ========================================== +# 3. GSB 전용 로직 (Google Safe Browsing) +# ========================================== def GSB_req_api(url: str | None) -> dict: - # [수정] 입력값 방어 - if not url: - return {"type": STATUS_INVALID_INPUT} - - # [수정] API 키 누락 방어 - if not GSB_API_KEY: - return {"type": STATUS_MISSING_API_KEY} + if not url: return {"type": STATUS_INVALID_INPUT, "data": None} + if not GSB_API_KEY: return {"type": STATUS_MISSING_API_KEY, "data": None} endpoint = f"https://safebrowsing.googleapis.com/v4/threatMatches:find?key={GSB_API_KEY}" body = GSB_req_build(url) start = time.time() - try: response = requests.post(endpoint, json=body, timeout=30) elapsed = time.time() - start try: response_data = response.json() if response.status_code == 200 else None - except ValueError: # [수정] JSON 파싱 실패 방어 + except ValueError: response_data = None return { @@ -195,90 +132,133 @@ def GSB_req_api(url: str | None) -> dict: "data": response_data, "elapsed": elapsed } - except requests.exceptions.Timeout: - return {"type": STATUS_TIMEOUT} - + return {"type": STATUS_TIMEOUT, "data": None} except requests.exceptions.RequestException: - return {"type": STATUS_REQUEST_FAILED} + return {"type": STATUS_REQUEST_FAILED, "data": None} +def GSB_req_build(url: str) -> dict: + return { + "client": {"clientId": "url-analysis-query", "clientVersion": "1.0"}, + "threatInfo": { + "threatTypes": ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION"], + "platformTypes": ["ANY_PLATFORM"], + "threatEntryTypes": ["URL"], + "threatEntries": [{"url": url}] + } + } def parse_GSB_result(raw: dict) -> dict: rtype = raw.get("type") if rtype != STATUS_SUCCESS: - return { - "is_threat": False, - "status_type": [None, rtype], - "result_type": False, - "threat_labels": [] - } + return {"is_threat": False, "status_type": [None, rtype], "result_type": False, "threat_labels": []} status_code = raw.get("status_code") if status_code != 200: - return { - "is_threat": False, - "status_type": [status_code, STATUS_CLIENT_ERROR], - "result_type": False, - "threat_labels": [] - } + return {"is_threat": False, "status_type": [status_code, STATUS_CLIENT_ERROR], "result_type": False, "threat_labels": []} data = raw.get("data") or {} matches = data.get("matches", []) if not matches: - return { - "is_threat": False, - "status_type": [200, STATUS_SUCCESS], - "result_type": True, - "threat_labels": [] - } + return {"is_threat": False, "status_type": [200, STATUS_SUCCESS], "result_type": True, "threat_labels": []} threat_types = list(set(m["threatType"] for m in matches)) labels = [f"{GSB_CODE}:{t}" for t in threat_types] - return { - "is_threat": True, - "status_type": [200, STATUS_SUCCESS], - "result_type": True, - "threat_labels": labels - } + return {"is_threat": True, "status_type": [200, STATUS_SUCCESS], "result_type": True, "threat_labels": labels} +# ========================================== +# 4. OTX 전용 로직 (AlienVault OTX) +# ========================================== +def OTX_req_api(url: str) -> dict: + if not url: return {"type": STATUS_INVALID_INPUT, "data": None} + if not OTX_API_KEY: return {"type": STATUS_MISSING_API_KEY, "data": None} -def build_api_result(name: str, parsed: dict) -> dict: - base = { - name: { - "is_threat": False, - "status_type": None, - "result_type": None, - "threat_labels": [] - }, - "report_count": None, - "block_count": None, - "domain_age": None - } + endpoint, headers = OTX_req_build(url) - base[name].update(parsed) - return base + try: + response = requests.get(endpoint, headers=headers, timeout=30) + try: + response_data = response.json() if response.status_code == 200 else None + except ValueError: + response_data = None + + return {"type": STATUS_SUCCESS, "status_code": response.status_code, "data": response_data} + except requests.exceptions.Timeout: + return {"type": STATUS_TIMEOUT, "data": None} + except requests.exceptions.RequestException: + return {"type": STATUS_REQUEST_FAILED, "data": None} +def OTX_req_build(url: str) -> tuple[str, dict]: + encoded_url = quote(url, safe='') + endpoint = f"https://otx.alienvault.com/api/v1/indicators/url/{encoded_url}/general" + headers = {"X-OTX-API-KEY": OTX_API_KEY} + return endpoint, headers -def GSB_req_build(url: str): - return { - "client": { - "clientId": "url-analysis-query", - "clientVersion": "1.0" - }, - "threatInfo": { - "threatTypes": [ - "MALWARE", - "SOCIAL_ENGINEERING", - "UNWANTED_SOFTWARE", - "POTENTIALLY_HARMFUL_APPLICATION" - ], - "platformTypes": ["ANY_PLATFORM"], - "threatEntryTypes": ["URL"], - "threatEntries": [ - {"url": url} - ] - } - } \ No newline at end of file +def parse_OTX_result(raw: dict) -> tuple[int, list]: + if raw.get("type") != STATUS_SUCCESS: + return 0, [] + + data = raw.get("data") or {} + pulse_info = data.get("pulse_info", {}) + count = pulse_info.get("count", 0) + + otx_labels = set() # 중복 방지를 위해 set 사용 + + if count > 0: + pulses = pulse_info.get("pulses", []) + + # 실무에서 추출하고자 하는 핵심 위협 키워드 정의 + # 이 리스트에 포함된 태그만 걸러내어 라벨로 만듭니다. + TARGET_THREAT_KEYWORDS = ["PHISHING", "MALWARE", "RANSOMWARE", "BOTNET", "SPAM", "C2"] + + for pulse in pulses: + tags = pulse.get("tags", []) + for tag in tags: + tag_upper = str(tag).upper() + + # 태그가 핵심 키워드 중 하나라면 "OTX:키워드" 형태로 추가 + if tag_upper in TARGET_THREAT_KEYWORDS: + otx_labels.add(f"OTX:{tag_upper}") + + # 만약 신고 내역(count > 0)은 있는데 쓸만한 태그가 단 하나도 없었다면? + # 기본 라벨을 부여하여 위협임을 명시합니다. + if not otx_labels: + otx_labels.add("OTX:SUSPICIOUS") + + return count, list(otx_labels) + +# ========================================== +# 5. WHOIS 전용 로직 (Domain Age) +# ========================================== +def WHOIS_req_api(url: str) -> dict: + if not url: return {"type": STATUS_INVALID_INPUT, "data": None} + + try: + ext = tldextract.extract(url) + domain = f"{ext.domain}.{ext.suffix}" + domain_info = whois.whois(domain) + return {"type": STATUS_SUCCESS, "data": domain_info} + except Exception: + return {"type": STATUS_REQUEST_FAILED, "data": None} + +def parse_WHOIS_result(raw: dict) -> str | None: + if raw.get("type") != STATUS_SUCCESS: + return None + + data = raw.get("data") + if not data: + return None + + # python-whois 객체 특성(dict와 object 혼용) 방어 로직 + creation_date = data.get("creation_date") if isinstance(data, dict) else getattr(data, "creation_date", None) + + if isinstance(creation_date, list): + creation_date = creation_date[0] + + if isinstance(creation_date, datetime): + return creation_date.strftime("%Y-%m-%d") + + return None \ No newline at end of file From c96192d36ab1502f3896c6a1913b5fb9e04d46d9 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Wed, 29 Apr 2026 18:05:01 +0900 Subject: [PATCH 52/77] =?UTF-8?q?refactor:=20req=5Fserver=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_server.py | 124 ----------------------- analyzers/req_server/__init__.py | 10 ++ analyzers/req_server/certificate.py | 151 ++++++++++++++++++++++++++++ analyzers/req_server/constants.py | 10 ++ analyzers/req_server/req_server.py | 29 ++++++ analyzers/req_server/url_utils.py | 115 +++++++++++++++++++++ 6 files changed, 315 insertions(+), 124 deletions(-) delete mode 100644 analyzers/req_server.py create mode 100644 analyzers/req_server/__init__.py create mode 100644 analyzers/req_server/certificate.py create mode 100644 analyzers/req_server/constants.py create mode 100644 analyzers/req_server/req_server.py create mode 100644 analyzers/req_server/url_utils.py diff --git a/analyzers/req_server.py b/analyzers/req_server.py deleted file mode 100644 index c6edaa8..0000000 --- a/analyzers/req_server.py +++ /dev/null @@ -1,124 +0,0 @@ -# 인증서 조회 -# 확장 가능: issuer 기반 탐지 로직 -import socket -import ssl - -from core.context import AnalysisContext - -STATUS_SUCCESS = "success" -STATUS_TIMEOUT = "timeout" -STATUS_SSL_ERROR = "ssl_error" -STATUS_VERIFY_FAILED = "verify_failed" -STATUS_REQUEST_FAILED = "request_failed" -STATUS_INVALID_INPUT = "invalid_input" - -CERT_TIMEOUT = 5 - - -def req_server(context: AnalysisContext) -> dict: - hostname = context.url.get("hostname") - - raw = cert_req_api(hostname) - parsed = parse_cert_result(raw) - - return build_server_result(parsed) - - -def cert_req_api(hostname: str | None) -> dict: - if not hostname: - return {"type": STATUS_INVALID_INPUT} - - try: - ssl_context = ssl.create_default_context() - - with socket.create_connection((hostname, 443), timeout=CERT_TIMEOUT) as sock: - with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: - cert = ssock.getpeercert() - - return { - "type": STATUS_SUCCESS, - "data": cert - } - - except socket.timeout: - return {"type": STATUS_TIMEOUT} - - except ssl.SSLCertVerificationError as e: - return { - "type": STATUS_VERIFY_FAILED, - "error": str(e) - } - - except ssl.SSLError as e: - return { - "type": STATUS_SSL_ERROR, - "error": str(e) - } - - except OSError as e: - return { - "type": STATUS_REQUEST_FAILED, - "error": str(e) - } - - -def parse_cert_result(raw: dict) -> dict: - rtype = raw.get("type") - - if rtype != STATUS_SUCCESS: - error = raw.get("error") - return { - "valid": False, - "issuer": None, - "validFrom": None, - "validTo": None, - "status_type": [rtype], - "error": [error] - } - - cert = raw.get("data") or {} - - issuer = parse_issuer(cert.get("issuer")) - valid_from = cert.get("notBefore") - valid_to = cert.get("notAfter") - - return { - "valid": True, - "issuer": issuer, - "validFrom": valid_from, - "validTo": valid_to, - "status_type": [STATUS_SUCCESS] - } - - -def build_server_result(parsed: dict) -> dict: - base = { - "valid": False, - "issuer": None, - "validFrom": None, - "validTo": None, - "status_type": None, - "error": [] - } - - base.update(parsed) - return base - - -def parse_issuer(issuer_tuple) -> str | None: - if not issuer_tuple: - return None - - flat = {} - - for group in issuer_tuple: - for key, value in group: - flat[key] = value - - if flat.get("organizationName"): - return flat["organizationName"] - - if flat.get("commonName"): - return flat["commonName"] - - return None diff --git a/analyzers/req_server/__init__.py b/analyzers/req_server/__init__.py new file mode 100644 index 0000000..14ae7c0 --- /dev/null +++ b/analyzers/req_server/__init__.py @@ -0,0 +1,10 @@ +from .certificate import build_server_result, cert_req_api, parse_cert_result, parse_issuer +from .req_server import req_server + +__all__ = [ + "build_server_result", + "cert_req_api", + "parse_cert_result", + "parse_issuer", + "req_server", +] diff --git a/analyzers/req_server/certificate.py b/analyzers/req_server/certificate.py new file mode 100644 index 0000000..01b0fa0 --- /dev/null +++ b/analyzers/req_server/certificate.py @@ -0,0 +1,151 @@ +import os +import socket +import ssl +import tempfile + +from .constants import ( + CERT_TIMEOUT, + STATUS_INVALID_INPUT, + STATUS_NO_CERTIFICATE, + STATUS_REQUEST_FAILED, + STATUS_SSL_ERROR, + STATUS_SUCCESS, + STATUS_TIMEOUT, + STATUS_UNEXPECTED_ERROR, + STATUS_VERIFY_FAILED, +) +from .url_utils import normalize_hostname, normalize_port + + +def cert_req_api(hostname: str | None, port: int | str | None = 443) -> dict: + hostname = normalize_hostname(hostname) + port = normalize_port(port) + + if not hostname or not port: + return {"type": STATUS_INVALID_INPUT} + + try: + ssl_context = ssl.create_default_context() + + with socket.create_connection((hostname, port), timeout=CERT_TIMEOUT) as sock: + sock.settimeout(CERT_TIMEOUT) + with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: + ssock.settimeout(CERT_TIMEOUT) + cert = ssock.getpeercert() + + return {"type": STATUS_SUCCESS, "data": cert} + + except (socket.timeout, TimeoutError): + return {"type": STATUS_TIMEOUT} + + except ssl.SSLCertVerificationError as e: + return { + "type": STATUS_VERIFY_FAILED, + "error": str(e), + "data": fetch_cert_without_verification(hostname, port), + } + + except ssl.SSLError as e: + return {"type": STATUS_SSL_ERROR, "error": str(e)} + + except OSError as e: + return {"type": STATUS_REQUEST_FAILED, "error": str(e)} + + except Exception as e: + return {"type": STATUS_UNEXPECTED_ERROR, "error": str(e)} + + +def parse_cert_result(raw: dict) -> dict: + if not isinstance(raw, dict): + return build_cert_failure(STATUS_UNEXPECTED_ERROR, "invalid certificate response") + + rtype = raw.get("type") or STATUS_UNEXPECTED_ERROR + + if rtype != STATUS_SUCCESS: + return build_cert_failure(rtype, raw.get("error"), cert=raw.get("data") or {}) + + cert = raw.get("data") or {} + if not cert: + return build_cert_failure(STATUS_NO_CERTIFICATE, "peer certificate not available") + + return { + "valid": True, + "issuer": parse_issuer(cert.get("issuer")), + "validFrom": cert.get("notBefore"), + "validTo": cert.get("notAfter"), + "status_type": [STATUS_SUCCESS], + "error": [], + } + + +def build_cert_failure( + status_type: str, + error: Exception | str | None = None, + *, + cert: dict | None = None, +) -> dict: + cert = cert or {} + return { + "valid": False, + "issuer": parse_issuer(cert.get("issuer")), + "validFrom": cert.get("notBefore"), + "validTo": cert.get("notAfter"), + "status_type": [status_type], + "error": [str(error)] if error else [], + } + + +def build_server_result(parsed: dict) -> dict: + result = { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": None, + "error": [], + } + result.update(parsed) + return result + + +def fetch_cert_without_verification(hostname: str, port: int) -> dict | None: + try: + ssl_context = ssl._create_unverified_context() + + with socket.create_connection((hostname, port), timeout=CERT_TIMEOUT) as sock: + sock.settimeout(CERT_TIMEOUT) + with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: + ssock.settimeout(CERT_TIMEOUT) + der_cert = ssock.getpeercert(binary_form=True) + + if not der_cert: + return None + + pem_cert = ssl.DER_cert_to_PEM_cert(der_cert) + with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as cert_file: + cert_file.write(pem_cert) + cert_path = cert_file.name + + try: + return ssl._ssl._test_decode_cert(cert_path) + finally: + os.unlink(cert_path) + + except Exception: + return None + + +def parse_issuer(issuer_tuple) -> str | None: + if not issuer_tuple: + return None + + flat = {} + for group in issuer_tuple: + if not isinstance(group, (list, tuple)): + continue + for item in group: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + flat[str(item[0])] = str(item[1]) + + return flat.get("organizationName") or flat.get("commonName") diff --git a/analyzers/req_server/constants.py b/analyzers/req_server/constants.py new file mode 100644 index 0000000..7656071 --- /dev/null +++ b/analyzers/req_server/constants.py @@ -0,0 +1,10 @@ +STATUS_SUCCESS = "success" +STATUS_TIMEOUT = "timeout" +STATUS_SSL_ERROR = "ssl_error" +STATUS_VERIFY_FAILED = "verify_failed" +STATUS_REQUEST_FAILED = "request_failed" +STATUS_INVALID_INPUT = "invalid_input" +STATUS_UNEXPECTED_ERROR = "unexpected_error" +STATUS_NO_CERTIFICATE = "no_certificate" + +CERT_TIMEOUT = 5 diff --git a/analyzers/req_server/req_server.py b/analyzers/req_server/req_server.py new file mode 100644 index 0000000..6f00829 --- /dev/null +++ b/analyzers/req_server/req_server.py @@ -0,0 +1,29 @@ +from core.context import AnalysisContext + +from .certificate import ( + build_cert_failure, + build_server_result, + cert_req_api, + parse_cert_result, +) +from .constants import STATUS_UNEXPECTED_ERROR +from .url_utils import parse_target_host + + +def req_server(context: AnalysisContext) -> dict: + hostname = context.url.get("hostname") + port = context.url.get("port") + + if not hostname: + hostname, fallback_port = parse_target_host(context) + port = port or fallback_port + + port = port or 443 + + try: + raw = cert_req_api(hostname, port) + parsed = parse_cert_result(raw) + except Exception as e: + parsed = build_cert_failure(STATUS_UNEXPECTED_ERROR, e) + + return build_server_result(parsed) diff --git a/analyzers/req_server/url_utils.py b/analyzers/req_server/url_utils.py new file mode 100644 index 0000000..6cb5d83 --- /dev/null +++ b/analyzers/req_server/url_utils.py @@ -0,0 +1,115 @@ +from urllib.parse import urlsplit + + +def parse_target_host(context) -> tuple[str | None, int | None]: + candidate_urls = [ + context.url.get("normalized_url"), + context.request.get("original_url"), + ] + + for candidate_url in candidate_urls: + hostname, port = parse_host_from_url(candidate_url) + if hostname: + return hostname, port + + return None, None + + +def parse_host_from_url(url: str | None) -> tuple[str | None, int | None]: + if not isinstance(url, str): + return None, None + + candidate = url.strip() + if not candidate: + return None, None + + split_result = urlsplit(candidate) + if not split_result.hostname: + split_result = urlsplit(f"https://{candidate}") + + try: + port = split_result.port + except ValueError: + port = None + + return split_result.hostname, port + + +def build_probe_urls(context, hostname: str, port: int | str | None) -> list[str]: + urls = [] + + for candidate_url in [ + context.url.get("normalized_url"), + context.request.get("original_url"), + ]: + url = normalize_probe_url(candidate_url) + candidate_hostname, _ = parse_host_from_url(url) + + if normalize_hostname(candidate_hostname) == hostname: + urls.append(url) + + netloc = build_netloc(hostname, port) + scheme = context.url.get("scheme") + if scheme in ("http", "https"): + urls.append(f"{scheme}://{netloc}") + + urls.append(f"https://{netloc}") + urls.append(f"http://{netloc}") + + return unique_values(urls) + + +def normalize_probe_url(url: str | None) -> str | None: + if not isinstance(url, str): + return None + + candidate = url.strip() + if not candidate: + return None + + if not urlsplit(candidate).hostname: + candidate = f"https://{candidate}" + + return candidate + + +def build_netloc(hostname: str, port: int | str | None) -> str: + normalized_port = normalize_port(port) + if normalized_port and normalized_port not in (80, 443): + return f"{hostname}:{normalized_port}" + return hostname + + +def normalize_hostname(hostname: str | None) -> str | None: + if not isinstance(hostname, str): + return None + + normalized = hostname.strip().strip("[]").rstrip(".").lower() + if not normalized: + return None + + try: + return normalized.encode("idna").decode("ascii") + except UnicodeError: + return None + + +def normalize_port(port: int | str | None) -> int | None: + try: + normalized = int(port or 443) + except (TypeError, ValueError): + return None + + if 1 <= normalized <= 65535: + return normalized + return None + + +def unique_values(values: list[str | None]) -> list[str]: + unique = [] + + for value in values: + if value and value not in unique: + unique.append(value) + + return unique From 2c9f433e6a024043b41e0584ac908c96c5f88412 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Wed, 29 Apr 2026 18:05:30 +0900 Subject: [PATCH 53/77] =?UTF-8?q?feat:=20=EC=84=9C=EB=B2=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_server/constants.py | 4 ++ analyzers/req_server/req_server.py | 2 + analyzers/req_server/server_info.py | 100 ++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 analyzers/req_server/server_info.py diff --git a/analyzers/req_server/constants.py b/analyzers/req_server/constants.py index 7656071..b1818e1 100644 --- a/analyzers/req_server/constants.py +++ b/analyzers/req_server/constants.py @@ -8,3 +8,7 @@ STATUS_NO_CERTIFICATE = "no_certificate" CERT_TIMEOUT = 5 +SERVER_INFO_TIMEOUT = 3 +UNKNOWN_SERVER_INFO = "unknown" +UNKNOWN_LOCATION_INFO = "unknown" +USER_AGENT = "veri-q-analysis-engine/1.0" diff --git a/analyzers/req_server/req_server.py b/analyzers/req_server/req_server.py index 6f00829..3e3ee08 100644 --- a/analyzers/req_server/req_server.py +++ b/analyzers/req_server/req_server.py @@ -7,6 +7,7 @@ parse_cert_result, ) from .constants import STATUS_UNEXPECTED_ERROR +from .server_info import update_context_server_info from .url_utils import parse_target_host @@ -19,6 +20,7 @@ def req_server(context: AnalysisContext) -> dict: port = port or fallback_port port = port or 443 + update_context_server_info(context, hostname, port) try: raw = cert_req_api(hostname, port) diff --git a/analyzers/req_server/server_info.py b/analyzers/req_server/server_info.py new file mode 100644 index 0000000..a00bf65 --- /dev/null +++ b/analyzers/req_server/server_info.py @@ -0,0 +1,100 @@ +import socket + +import requests + +from .constants import ( + SERVER_INFO_TIMEOUT, + UNKNOWN_LOCATION_INFO, + UNKNOWN_SERVER_INFO, + USER_AGENT, +) +from .url_utils import build_probe_urls, normalize_hostname + +try: + requests.packages.urllib3.disable_warnings() +except AttributeError: + pass + + +def update_context_server_info(context, hostname: str | None, port: int | str | None) -> None: + if not isinstance(getattr(context, "external", None), dict): + context.external = {} + + context.external["server_type"] = fetch_server_type(context, hostname, port) + context.external["server_location"] = fetch_server_location(hostname) + + +def fetch_server_type(context, hostname: str | None, port: int | str | None) -> str: + hostname = normalize_hostname(hostname) + if not hostname: + return UNKNOWN_SERVER_INFO + + for url in build_probe_urls(context, hostname, port): + try: + response = requests.head( + url, + allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + timeout=SERVER_INFO_TIMEOUT, + verify=False, + ) + server_type = response.headers.get("Server") + response.close() + + if server_type: + return server_type.strip() + + except requests.exceptions.RequestException: + continue + + return UNKNOWN_SERVER_INFO + + +def fetch_server_location(hostname: str | None) -> str: + hostname = normalize_hostname(hostname) + if not hostname: + return UNKNOWN_LOCATION_INFO + + ip_address = resolve_ip_address(hostname) + if not ip_address: + return UNKNOWN_LOCATION_INFO + + try: + response = requests.get( + f"http://ip-api.com/json/{ip_address}", + params={"fields": "status,country,countryCode,region,regionName,city,query"}, + timeout=SERVER_INFO_TIMEOUT, + ) + data = response.json() + response.close() + + if data.get("status") == "success": + return format_location(data) or ip_address + + except (requests.exceptions.RequestException, ValueError): + pass + + return ip_address + + +def format_location(data: dict) -> str: + parts = [ + data.get("city"), + data.get("regionName"), + data.get("country"), + ] + return ", ".join(part for part in parts if part) + + +def resolve_ip_address(hostname: str) -> str | None: + try: + address_info = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except OSError: + return None + + for family in (socket.AF_INET, socket.AF_INET6): + for info in address_info: + if info[0] == family: + return info[4][0] + + return None From bb8000ab9e803a45e41f33cf4d86731604478d60 Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Wed, 29 Apr 2026 18:06:16 +0900 Subject: [PATCH 54/77] =?UTF-8?q?feat:=20=EC=9C=84=EC=B9=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=ED=95=9C=EA=B5=AD=EC=96=B4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_server/constants.py | 2 +- analyzers/req_server/location.py | 70 +++++++++++++++++++++++++++++ analyzers/req_server/server_info.py | 12 +---- 3 files changed, 73 insertions(+), 11 deletions(-) create mode 100644 analyzers/req_server/location.py diff --git a/analyzers/req_server/constants.py b/analyzers/req_server/constants.py index b1818e1..e026dd3 100644 --- a/analyzers/req_server/constants.py +++ b/analyzers/req_server/constants.py @@ -10,5 +10,5 @@ CERT_TIMEOUT = 5 SERVER_INFO_TIMEOUT = 3 UNKNOWN_SERVER_INFO = "unknown" -UNKNOWN_LOCATION_INFO = "unknown" +UNKNOWN_LOCATION_INFO = "위치 정보 없음" USER_AGENT = "veri-q-analysis-engine/1.0" diff --git a/analyzers/req_server/location.py b/analyzers/req_server/location.py new file mode 100644 index 0000000..c8b383a --- /dev/null +++ b/analyzers/req_server/location.py @@ -0,0 +1,70 @@ +COUNTRY_NAMES_KO = { + "KR": "대한민국", + "US": "미국", + "JP": "일본", + "CN": "중국", + "GB": "영국", + "DE": "독일", + "FR": "프랑스", + "CA": "캐나다", + "AU": "호주", + "SG": "싱가포르", + "HK": "홍콩", + "TW": "대만", +} + +KR_REGION_NAMES_KO = { + "11": "서울", + "26": "부산", + "27": "대구", + "28": "인천", + "29": "광주", + "30": "대전", + "31": "울산", + "36": "세종", + "41": "경기도", + "42": "강원도", + "43": "충청북도", + "44": "충청남도", + "45": "전라북도", + "46": "전라남도", + "47": "경상북도", + "48": "경상남도", + "49": "제주도", +} + +US_REGION_NAMES_KO = { + "CA": "캘리포니아주", + "IA": "아이오와주", + "NY": "뉴욕주", + "TX": "텍사스주", + "VA": "버지니아주", + "WA": "워싱턴주", +} + + +def format_location_ko(data: dict) -> str: + country_code = data.get("countryCode") + region_code = data.get("region") + country = COUNTRY_NAMES_KO.get(country_code, data.get("country")) + region = format_region_ko(country_code, region_code, data.get("regionName")) + city = data.get("city") + + parts = [] + for part in (country, region, city): + if part and part not in parts: + parts.append(part) + + return " ".join(parts) + + +def format_region_ko( + country_code: str | None, + region_code: str | None, + region_name: str | None, +) -> str | None: + if country_code == "KR": + return KR_REGION_NAMES_KO.get(region_code, region_name) + if country_code == "US": + return US_REGION_NAMES_KO.get(region_code, region_name) + return region_name diff --git a/analyzers/req_server/server_info.py b/analyzers/req_server/server_info.py index a00bf65..1450dc7 100644 --- a/analyzers/req_server/server_info.py +++ b/analyzers/req_server/server_info.py @@ -8,6 +8,7 @@ UNKNOWN_SERVER_INFO, USER_AGENT, ) +from .location import format_location_ko from .url_utils import build_probe_urls, normalize_hostname try: @@ -69,7 +70,7 @@ def fetch_server_location(hostname: str | None) -> str: response.close() if data.get("status") == "success": - return format_location(data) or ip_address + return format_location_ko(data) or ip_address except (requests.exceptions.RequestException, ValueError): pass @@ -77,15 +78,6 @@ def fetch_server_location(hostname: str | None) -> str: return ip_address -def format_location(data: dict) -> str: - parts = [ - data.get("city"), - data.get("regionName"), - data.get("country"), - ] - return ", ".join(part for part in parts if part) - - def resolve_ip_address(hostname: str) -> str | None: try: address_info = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) From 977beb6255ae4ff75d320ea9b5c907c9c3aae17b Mon Sep 17 00:00:00 2001 From: kimsubsub Date: Wed, 29 Apr 2026 18:44:34 +0900 Subject: [PATCH 55/77] =?UTF-8?q?fix:=20=EB=B6=84=EC=84=9D=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EB=9F=B0=ED=83=80=EC=9E=84=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 6 ++++-- services/url_utils.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index a7179e4..88f2948 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,5 +13,7 @@ starlette==1.0.0 typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.44.0 -pymysql==1.1.2 -requests==2.32.3 \ No newline at end of file +pymysql==1.1.2 +python-whois==0.9.6 +requests==2.32.3 +tldextract==5.3.1 diff --git a/services/url_utils.py b/services/url_utils.py index b939fa1..05cc8a7 100644 --- a/services/url_utils.py +++ b/services/url_utils.py @@ -140,5 +140,5 @@ def normalize_url(context: AnalysisContext) -> None: context.url["has_explicit_scheme"] = scheme_result["had_scheme_originally"] context.url["is_default_port"] = normalized_comp["is_default_port"] - # 정규화 수행 완료 표시 - context.flags["normalized"] = True \ No newline at end of file + # 정규화 수행 완료 표시 + context.meta["normalized"] = True From b6bf0fdbb4cac32d7600af1f49c9cefc6202c604 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 1 May 2026 11:49:11 +0900 Subject: [PATCH 56/77] =?UTF-8?q?fetch=20=EC=98=A4=EB=A5=98=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=ED=95=9C=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug-report.yml | 22 ++ .github/ISSUE_TEMPLATE/feature-request.yml | 34 +++ .github/ISSUE_TEMPLATE/refactor-task.yml | 44 ++++ .github/ISSUE_TEMPLATE/refactor.yml | 29 +++ .github/PULL_REQUEST_TEMPLATE.md | 13 + .github/workflows/deploy.yml | 29 +++ .gitignore | 208 ++++++++++++++++ .idea/.gitignore | 10 + .idea/analysis-engine-develop.iml | 9 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + Dockerfile | 6 + README.md | 23 ++ analyzers/ai_analyze.py | 50 ++++ analyzers/api_test.py | 35 +++ analyzers/parameter_rule.py | 141 +++++++++++ analyzers/redirect_tracker.py | 189 +++++++++++++++ analyzers/req_api.py | 264 +++++++++++++++++++++ analyzers/req_server/__init__.py | 10 + analyzers/req_server/certificate.py | 151 ++++++++++++ analyzers/req_server/constants.py | 14 ++ analyzers/req_server/location.py | 70 ++++++ analyzers/req_server/req_server.py | 31 +++ analyzers/req_server/server_info.py | 92 +++++++ analyzers/req_server/url_utils.py | 115 +++++++++ analyzers/url_shorten_checker.py | 72 ++++++ core/context.py | 101 ++++++++ core/pipeline.py | 83 +++++++ docker-compose.yml | 64 +++++ main.py | 33 +++ models/base.py | 13 + models/receive.py | 11 + models/result.py | 52 ++++ requirements.txt | 19 ++ services/builder.py | 106 +++++++++ services/response.py | 88 +++++++ services/scoring.py | 196 +++++++++++++++ services/url_utils.py | 144 +++++++++++ 39 files changed, 2591 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yml create mode 100644 .github/ISSUE_TEMPLATE/refactor-task.yml create mode 100644 .github/ISSUE_TEMPLATE/refactor.yml create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/deploy.yml create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/analysis-engine-develop.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 analyzers/ai_analyze.py create mode 100644 analyzers/api_test.py create mode 100644 analyzers/parameter_rule.py create mode 100644 analyzers/redirect_tracker.py create mode 100644 analyzers/req_api.py create mode 100644 analyzers/req_server/__init__.py create mode 100644 analyzers/req_server/certificate.py create mode 100644 analyzers/req_server/constants.py create mode 100644 analyzers/req_server/location.py create mode 100644 analyzers/req_server/req_server.py create mode 100644 analyzers/req_server/server_info.py create mode 100644 analyzers/req_server/url_utils.py create mode 100644 analyzers/url_shorten_checker.py create mode 100644 core/context.py create mode 100644 core/pipeline.py create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 models/base.py create mode 100644 models/receive.py create mode 100644 models/result.py create mode 100644 requirements.txt create mode 100644 services/builder.py create mode 100644 services/response.py create mode 100644 services/scoring.py create mode 100644 services/url_utils.py diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 0000000..7c20af2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,22 @@ + name: "🛡️ VeriQ 스캔 오류 보고" + description: "QR 스캔이나 분석 과정에서의 문제를 알려주세요." + body: + - type: input + id: scheme + attributes: + label: "스캔한 스킴 종류" + placeholder: "예: WEB, SHORT_URL, TEL 등" + validations: + required: true + - type: dropdown + id: environment + attributes: + label: "실행 환경" + options: + - Local (localhost) + - Remote + - type: textarea + id: description + attributes: + label: "상세 설명" + description: "어떤 문제가 발생했나요?" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 0000000..d82984f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,34 @@ + name: "🚀 신규 기능 제안" + description: "VeriQ에 추가하고 싶은 새로운 아이디어나 개선 사항을 제안합니다." + title: "[FEAT] " + labels: ["enhancement"] + body: + - type: textarea + id: problem + attributes: + label: "어떤 문제가 있나요?" + description: "기존 기능에서 불편한 점이나, 해결하고 싶은 문제 상황을 설명해 주세요." + placeholder: + validations: + required: true + - type: textarea + id: solution + attributes: + label: "어떤 기능이 추가되면 좋을까요?" + description: "제안하시는 해결 방안이나 새로운 기능을 자세히 설명해 주세요." + placeholder: + validations: + required: true + - type: checkboxes + id: checklist + attributes: + label: "작업 체크리스트" + options: + - label: "기존 기능에 영향을 주지 않는가?" + - label: "BE2(분석 서버)와의 통신이 필요한가?" + - label: "새로운 API 엔드포인트가 필요한가?" + - type: textarea + id: context + attributes: + label: "추가 정보" + description: "스크린샷, 관련 문서 링크, 혹은 참고할만한 타 서비스 사례가 있다면 적어주세요." \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/refactor-task.yml b/.github/ISSUE_TEMPLATE/refactor-task.yml new file mode 100644 index 0000000..88916ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-task.yml @@ -0,0 +1,44 @@ + name: "🧹 리팩토링 및 기술 부채" + description: "코드 구조 개선, 성능 최적화, 혹은 오래된 로직을 정리하는 작업을 기록합니다." + title: "[REFACTOR] " + labels: ["refactor", "tech-debt"] + body: + - type: input + id: target + attributes: + label: "대상 모듈/클래스" + placeholder: "예: com.veriq.veriqbe3.service.ProcessQrScan" + validations: + required: true + - type: dropdown + id: priority + attributes: + label: "우선순위" + options: + - Low + - Medium + - High + - Critical + validations: + required: true + - type: textarea + id: motivation + attributes: + label: "리팩토링 이유" + description: "왜 이 코드를 수정해야 하나요? (가독성, 성능, 테스트 용이성 등)" + placeholder: "예: 현재 WebClient 로직이 복잡하여 에러 처리를 분리할 필요가 있습니다." + validations: + required: true + - type: textarea + id: changes + attributes: + label: "상세 변경 내용" + description: "어떤 방식으로 코드를 변경할 계획인가요?" + - type: checkboxes + id: quality_check + attributes: + label: "품질 확인" + options: + - label: "기존 단위 테스트를 통과하는가?" + - label: "새로운 테스트 코드를 작성할 것인가?" + - label: "문서(README 등) 업데이트가 필요한가?" \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/refactor.yml b/.github/ISSUE_TEMPLATE/refactor.yml new file mode 100644 index 0000000..7f3a4af --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor.yml @@ -0,0 +1,29 @@ +name: '✨ Feature' +description: '새로운 기능이나 제품 요구사항을 제안합니다.' +title: '[Feature]: ' +labels: 'feature' +body: + - type: textarea + id: proposal + attributes: + label: 기능 설명 + description: 추가하거나 변경하려는 기능을 구체적으로 작성해 주세요. + placeholder: 자세히 적을수록 좋습니다. + validations: + required: true + - type: textarea + id: tasks + attributes: + label: 작업 항목 + description: 구현해야 할 일을 최대한 세분화해서 작성해 주세요. + placeholder: |- + - 결과 화면 문구 설계 + - UI 구현 + - 빈 상태 처리 + validations: + required: true + - type: textarea + id: etc + attributes: + label: 참고 자료 + description: 더 전달할 내용이나 레퍼런스가 있다면 작성해 주세요. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ac066d8 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## 📌 작업 개요 +- 작업 목적 또는 관련 이슈 번호를 적어주세요. (예: #123) + +## 🚀 작업 내용 +- 이번 PR에서 변경된 핵심 내용을 요약해주세요. + +## 📸 스크린샷 (선택) +- UI 변경이 있다면 캡쳐 이미지를 첨부해주세요. + +## ✅ 체크리스트 +- [ ] 코딩 컨벤션을 준수했나요? +- [ ] 불필요한 주석이나 log를 제거했나요? +- [ ] 테스트를 통과했나요? diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..395749f --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: FastAPI Auto Deploy + +on: + push: + branches: [ "develop" ] + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: SSH Remote Commands for FastAPI + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.GCP_IP }} + username: ${{ secrets.GCP_USER }} + key: ${{ secrets.GCP_SSH_KEY }} + command_timeout: 30m + script: | + # 1. 파이썬 공용 폴더로 이동해서 최신 코드 땡겨오기 + cd /opt/analysis-engine + git pull origin develop + + # 2. 도커 설계도가 있는 메인 서버 폴더로 이동! + cd /opt/main-server + + # 3. 파이썬(veriq-analysis) 컨테이너만 콕 집어서 다시 빌드하고 실행 + sudo docker-compose up --build -d veriq-analysis + + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0cfe250 --- /dev/null +++ b/.gitignore @@ -0,0 +1,208 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ +.vscode + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/analysis-engine-develop.iml b/.idea/analysis-engine-develop.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/analysis-engine-develop.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..6f29fee --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..203c8f1 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f8fda52 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ed693aa --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# analysis-engine +be2 +python- FastAPI +api: google safe browsing + +*** + +## 현재 단계 : API, DB 조회 오류 수정, URL정규화+단축/https검사 구현 +#### 지난 단계: API, DB 조회 구현 + +조회 부분까지의 업로드를 다른 브랜치에 하려 했으나 실수로 main에 집어넣었습니다. +오류 수정을 포함한 이후 단계부터는 develop, 또는 develop/기능 이름 브랜치에서 진행합니다. + +*** + +### API, DB 조회 상세 설명 ++ 테스트를 위한 프로토타입 목적입니다. (최종 형태 X) + + 작동 구조만 맞추는 것에 가깝습니다. + + 더미 데이터로 테스트하며, 응답을 저장하지 않습니다. ++ API는 google safe browsing만을 조회합니다. ++ DB 조회 형식이 확정되지 않았습니다. + + case1> 요청-응답 : 분석 서버에서 메인 서버로 조회 요청 전송, 조회 결과를 응답받습니다. <현재 + + case2> 직접 조회(pymysql) : 분석 서버에서 MySQL DB로 직접 질의를 통해 조회합니다. diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py new file mode 100644 index 0000000..b1897fb --- /dev/null +++ b/analyzers/ai_analyze.py @@ -0,0 +1,50 @@ +# pre-trained된 ML모델을 호출해 분석을 진행합니다. +# ML 기반 검사 로직입니다. +# 현재는 학습된 모델 대신 더미 추론 결과를 반환합니다. +from core.context import AnalysisContext + + +# [수정] ML 입력 URL 결정 함수 추가 +def resolve_ml_target_url(context: AnalysisContext) -> str | None: + if context.detection["shortened_url"]["detected"] == True: + return context.redirect.get("final_url") or context.url.get("normalized_url") + + if context.redirect.get("was_checked") and context.redirect.get("final_url"): + return context.redirect.get("final_url") + + return context.url.get("normalized_url") or context.request.get("original_url") + + +def ai_analyze(context: AnalysisContext) -> dict: + # [수정] final_url 고정 사용 제거 + target_url = resolve_ml_target_url(context) + + result = run_ml_inference(target_url) + + return build_ml_result( + score=result["score"], + threats=result["threats"] + ) + + +def run_ml_inference(target_url: str | None) -> dict: + if not target_url: + return { + "score": 0, + "threats": [] + } + + return { + "score": 61, + "threats": ["dummy_ml_threat"] + } + + +def build_ml_result(score: int = 0, threats: list | None = None) -> dict: + if threats is None: + threats = [] + + return { + "score": score, + "threats": threats + } \ No newline at end of file diff --git a/analyzers/api_test.py b/analyzers/api_test.py new file mode 100644 index 0000000..2dde98a --- /dev/null +++ b/analyzers/api_test.py @@ -0,0 +1,35 @@ +# req_api 테스트 코드 +from req_api import req_api # 제공해주신 코드가 담긴 파일명으로 변경하세요. + +# 1. AnalysisContext를 흉내 내는 Mock 클래스 생성 +class MockContext: + def __init__(self, url: str): + # 원본 코드에서 context.url.get("normalized_url") 형태로 접근하므로 딕셔너리로 만듭니다. + self.url = {"normalized_url": url} + +def run_test(): + # 테스트용 URL 리스트 + test_urls = [ + # 1. 정상 URL 테스트 + "https://www.google.com", + + # 2. 악성 URL 테스트 (Google이 제공하는 Safe Browsing 테스트용 안전한 악성 URL) + "https://ey43.com/4/10379548" + ] + + for target_url in test_urls: + print(f"\n[테스트 시작] URL: {target_url}") + + # Mock Context 객체 생성 + context = MockContext(target_url) + + # 핵심 함수 호출 + result = req_api(context) + + # 결과 출력 + print("결과:") + import json + print(json.dumps(result, indent=4, ensure_ascii=False)) + +if __name__ == "__main__": + run_test() \ No newline at end of file diff --git a/analyzers/parameter_rule.py b/analyzers/parameter_rule.py new file mode 100644 index 0000000..757007d --- /dev/null +++ b/analyzers/parameter_rule.py @@ -0,0 +1,141 @@ +# 룰 기반의 파라미터 분석 로직입니다. +# query 파라미터, path, 인코딩 등을 분석해 의심스러운 사항을 기록합니다. +import re +from urllib.parse import parse_qsl, unquote + +from core.context import AnalysisContext + +FLAG_PERCENT_ENCODING = "percent_encoding_detected" +FLAG_DOUBLE_ENCODING = "double_encoding_suspected" + +FLAG_SUSPICIOUS_QUERY_PARAM = "suspicious_query_param_detected" +FLAG_EMBEDDED_URL_IN_QUERY = "embedded_url" +FLAG_SUSPICIOUS_QUERY_KEYWORD = "suspicious_query_keyword_detected" +SUSPICIOUS_QUERY_PARAM_NAMES = { + "url", "redirect", "redir", "next", "continue", + "target", "dest", "destination", "return", + "returnurl", "return_url", "callback" +} +SUSPICIOUS_KEYWORDS = { + "login", "signin", "verify", "verification", + "secure", "account", "update", "bank", + "payment", "confirm", "wallet", "auth" +} +FLAG_SUSPICIOUS_PATH_KEYWORD = "suspicious_path_keyword_detected" +FLAG_SUSPICIOUS_FRAGMENT_KEYWORD = "suspicious_fragment_keyword_detected" + +PERCENT_ENCODING_PATTERN = re.compile(r"%[0-9A-Fa-f]{2}") + + + +# 룰 기반 검사 수행 함수 +# context.url에서 필요한 값을 읽어 rule_based 형식의 dict를 반환 +def parameter_rule(context: AnalysisContext) -> dict: + path = context.url.get("path", "") + query = context.url.get("query", "") + fragment = context.url.get("fragment", "") + + result = build_rule_based_result() + + analyze_encoding(path, query, fragment, result) + analyze_query(query, result) + analyze_path(path, result) + analyze_fragment(fragment, result) + + return result + + +# 인코딩 검사- 퍼센트 인코딩 여부, 이중 인코딩 의심 여부를 분석 +def analyze_encoding(path: str, query: str, fragment: str, result: dict) -> None: + raw_text = f"{path}{query}{fragment}" + + # 퍼센트 인코딩 여부 + if PERCENT_ENCODING_PATTERN.search(raw_text): + if FLAG_PERCENT_ENCODING not in result["flags"]: + result["flags"].append(FLAG_PERCENT_ENCODING) + + # 1회 decode 후에도 %xx 패턴이 남으면 이중 인코딩 의심 + decoded_once = unquote(raw_text) + + if PERCENT_ENCODING_PATTERN.search(raw_text) and PERCENT_ENCODING_PATTERN.search(decoded_once): + if FLAG_DOUBLE_ENCODING not in result["flags"]: + result["flags"].append(FLAG_DOUBLE_ENCODING) + + +# query 문자열 분석 +def analyze_query(query: str, result: dict) -> None: + query_pairs = parse_qsl(query, keep_blank_values=True) + + for raw_key, raw_value in query_pairs: + key = raw_key.lower() + decoded_value = unquote(raw_value) + lower_value = decoded_value.lower() + + # 의심되는 파라미터 값 포함 여부 확인 + if key in SUSPICIOUS_QUERY_PARAM_NAMES: + if raw_key not in result["suspicious_query_params"]: + result["suspicious_query_params"].append(raw_key) + + if FLAG_SUSPICIOUS_QUERY_PARAM not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_QUERY_PARAM) + + # query값에 내포된 URL 존재여부 검사 + if ( + "http://" in lower_value or + "https://" in lower_value or + "www." in lower_value + ): + if decoded_value not in result["embedded_urls"]: + result["embedded_urls"].append(decoded_value) + + if FLAG_EMBEDDED_URL_IN_QUERY not in result["flags"]: + result["flags"].append(FLAG_EMBEDDED_URL_IN_QUERY) + + # query 값 안의 의심 키워드 검사 + for keyword in SUSPICIOUS_KEYWORDS: + if keyword in lower_value: + if keyword not in result["suspicious_keywords"]["query"]: + result["suspicious_keywords"]["query"].append(keyword) + + if FLAG_SUSPICIOUS_QUERY_KEYWORD not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_QUERY_KEYWORD) + + +# path 안의 의심 키워드 검사 +def analyze_path(path: str, result: dict) -> None: + lower_path = unquote(path).lower() + + for keyword in SUSPICIOUS_KEYWORDS: + if keyword in lower_path: + if keyword not in result["suspicious_keywords"]["path"]: + result["suspicious_keywords"]["path"].append(keyword) + + if FLAG_SUSPICIOUS_PATH_KEYWORD not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_PATH_KEYWORD) + + +# fragment 안의 의심 키워드 검사 +def analyze_fragment(fragment: str, result: dict) -> None: + lower_fragment = unquote(fragment).lower() + + for keyword in SUSPICIOUS_KEYWORDS: + if keyword in lower_fragment: + if keyword not in result["suspicious_keywords"]["fragment"]: + result["suspicious_keywords"]["fragment"].append(keyword) + + if FLAG_SUSPICIOUS_FRAGMENT_KEYWORD not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_FRAGMENT_KEYWORD) + + +# rule_based 형식의 결과 dict 조립 +def build_rule_based_result() -> dict: + return { + "flags": [], + "suspicious_query_params": [], + "embedded_urls": [], + "suspicious_keywords": { + "path": [], + "query": [], + "fragment": [] + }, + } \ No newline at end of file diff --git a/analyzers/redirect_tracker.py b/analyzers/redirect_tracker.py new file mode 100644 index 0000000..a61130a --- /dev/null +++ b/analyzers/redirect_tracker.py @@ -0,0 +1,189 @@ +# 리다이렉트 추적 검사 +import requests +from urllib.parse import urljoin +from core.context import AnalysisContext + + +REQUEST_TIMEOUT = 30 +MAX_REDIRECT_HOPS = 30 + +STATUS_SUCCESS = "success" +STATUS_CLIENT_ERROR = "client_error" +STATUS_REDIRECT = "redirect" +STATUS_REQUEST_FAILED = "request_failed" +STATUS_LOOP_DETECTED = "loop_detected" +STATUS_TOO_MANY_REDIRECTS = "too_many_redirects" +STATUS_INVALID_LOCATION = "invalid_location" + + + +def redirect_tracker(context: AnalysisContext) -> dict: + # 단축 URL : 단축 URL을 시작 URL로 함 + # 일반 URL : 정규화를 거친 URL을 시작 URL로 함 + if context.detection["shortened_url"]["detected"] is True: + start_url = context.request.get("original_url") + else: + start_url = context.url.get("normalized_url") + + if start_url and not start_url.startswith(("http://", "https://")): + start_url = "https://" + start_url + + current_url = start_url + visited_urls = set() + redirect_count = 0 + redirect_chain = [] + + while True: + # 루프 검사 + if current_url in visited_urls: + redirect_chain.append({ + "url": current_url, + "status_code": None, + "status_label": STATUS_LOOP_DETECTED, + "location": None + }) + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + loop_detected=True, + status_type=[None, STATUS_LOOP_DETECTED], + ) + + # 방문했던 URL 리스트에 현재 URL 추가 + visited_urls.add(current_url) + + # 리다이렉트 요청 + response_info = request_once(current_url) + + status_code = response_info["status_code"] + location = response_info["location"] + status_type = response_info["status_type"] + + redirect_chain.append({ + "url": current_url, + "status_code": status_code, + "status_label": status_type, + "location": location + }) + + #---------- 리다이렉트 추적 + if status_type == STATUS_REDIRECT: + next_url = build_next_url(current_url, location) + + if not next_url: + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_INVALID_LOCATION], + ) + + redirect_count += 1 + + if redirect_count > MAX_REDIRECT_HOPS: + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_TOO_MANY_REDIRECTS], + ) + + current_url = next_url + continue + + #---------- 추적 종료 + if status_type == STATUS_SUCCESS: + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_SUCCESS], + ) + + # 클라이언트 에러 종료 + if status_type == STATUS_CLIENT_ERROR: + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_CLIENT_ERROR], + ) + + # 요청 실패 / 기타 종료 + return build_redirect_result( + redirect_count=redirect_count, + final_url=current_url, + redirect_chain=redirect_chain, + status_type=[status_code, STATUS_REQUEST_FAILED], + ) + + +# 상태코드 분류 +# 3xx+Location을 리다이렉트로 분류 +def classify_response_status(status_code: int, location: str | None) -> str: + if 200 <= status_code < 300: + return STATUS_SUCCESS + + if 300 <= status_code < 400: + if location: + return STATUS_REDIRECT + return STATUS_REQUEST_FAILED + + if 400 <= status_code < 500: + return STATUS_CLIENT_ERROR + + return STATUS_REQUEST_FAILED + + +# 현 URL에 한 번 요청, 자동 리다이렉트 X +def request_once(current_url: str) -> dict: + try: + response = requests.get( + current_url, + allow_redirects=False, + timeout=REQUEST_TIMEOUT + ) + + location = response.headers.get("Location") + status_type = classify_response_status(response.status_code, location) + + return { + "status_code": response.status_code, + "location": location, + "status_type": status_type, + } + + except requests.exceptions.RequestException as e: + return { + "status_code": None, + "location": None, + "status_type": STATUS_REQUEST_FAILED, + } + + +# 현 URL+Location 헤더로 이동할 URL 생성 +# location 존재 X->None, 상대 경로는 현재 URL 기준 변환 +def build_next_url(current_url: str, location: str | None) -> str | None: + if not location: + return None + return urljoin(current_url, location) + + +# context.redirect 형식에 맞춘 결과 dict 조립 +def build_redirect_result(**overrides) -> dict: + result = { + "was_checked": True, + "redirect_count": 0, + "final_url": None, + "redirect_chain": [], + "loop_detected": False, + "status_type": [None, None], + } + + for key in overrides: + if key not in result: + raise KeyError(f"허용되지 않은 redirect 필드: {key}") + + result.update(overrides) + return result \ No newline at end of file diff --git a/analyzers/req_api.py b/analyzers/req_api.py new file mode 100644 index 0000000..910f4f9 --- /dev/null +++ b/analyzers/req_api.py @@ -0,0 +1,264 @@ +# 외부 api 조회 +import os +import time +import whois +import tldextract +import requests + +from urllib.parse import quote +from datetime import datetime +from dotenv import load_dotenv +from core.context import AnalysisContext + +load_dotenv() + +GSB_CODE = "GSB" +GSB_API_KEY = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY") +OTX_API_KEY = os.getenv("OTX_API_KEY") + +STATUS_SUCCESS = "success" +STATUS_TIMEOUT = "timeout" +STATUS_REQUEST_FAILED = "request_failed" +STATUS_INVALID_INPUT = "invalid_input" +STATUS_CLIENT_ERROR = "client_error" +STATUS_MISSING_API_KEY = "missing_api_key" + +# ========================================== +# 1. 공통 유틸리티 및 화이트리스트 로직 +# ========================================== +def is_whitelisted(url: str) -> bool: + """URL의 도메인이 화이트리스트에 포함되어 있는지 확인""" + if not url: + return False + + WHITELIST_DOMAINS = { + "google.com", "google.co.kr", "naver.com", "daum.net", "yahoo.com", "bing.com", + "facebook.com", "instagram.com", "twitter.com", "linkedin.com", "youtube.com", "kakao.com", + "apple.com", "microsoft.com", "github.com", "stackoverflow.com", "cloudflare.com", "amazon.com", "aws.com" + } + + SAFE_SUFFIXES = { + "go.kr", "ac.kr", "re.kr", "mil.kr", "es.kr", "ms.kr", "hs.kr", "sc.kr", + "gov", "edu", "mil", "int" + } + + try: + ext = tldextract.extract(url) + domain = f"{ext.domain}.{ext.suffix}" + + if domain in WHITELIST_DOMAINS: + return True + if ext.suffix in SAFE_SUFFIXES: + return True + + return False + except Exception: + return False + +# ========================================== +# 2. 메인 오케스트레이션 로직 +# ========================================== +def req_api(context: AnalysisContext) -> dict: + target_url = context.url.get("normalized_url") + + # [1] GSB 파이프라인 + raw_gsb = GSB_req_api(target_url) + parsed_gsb_result = parse_GSB_result(raw_gsb) + + # [2] OTX 파이프라인 + report_count = 0 + if not is_whitelisted(target_url): + raw_otx = OTX_req_api(target_url) + + # [수정] count와 동적으로 생성된 라벨 리스트를 함께 받아옵니다. + report_count, otx_labels = parse_OTX_result(raw_otx) + + if report_count > 0: + parsed_gsb_result["is_threat"] = True + + # [수정] 무조건 "OTX:MALWARE"를 넣는 대신, 추출해 낸 라벨들을 순회하며 추가 + for label in otx_labels: + if label not in parsed_gsb_result["threat_labels"]: + parsed_gsb_result["threat_labels"].append(label) + + # [3] WHOIS 파이프라인 + raw_whois = WHOIS_req_api(target_url) + domain_age = parse_WHOIS_result(raw_whois) + + # [4] 최종 조립 + return build_api_result("safe_browsing", parsed_gsb_result, report_count, domain_age) + + +def build_api_result(name: str, parsed_gsb: dict, report_count: int, domain_age: str | None) -> dict: + """모든 파이프라인의 결과를 모아 최종 포맷으로 조립 (기존 구조 복원)""" + base = { + name: { + "is_threat": False, + "status_type": None, + "result_type": None, + "threat_labels": [] + }, + "report_count": report_count, + "block_count": 0, # 내부 DB 미사용 시 0 + "domain_age": domain_age + } + + base[name].update(parsed_gsb) + return base + +# ========================================== +# 3. GSB 전용 로직 (Google Safe Browsing) +# ========================================== +def GSB_req_api(url: str | None) -> dict: + if not url: return {"type": STATUS_INVALID_INPUT, "data": None} + if not GSB_API_KEY: return {"type": STATUS_MISSING_API_KEY, "data": None} + + endpoint = f"https://safebrowsing.googleapis.com/v4/threatMatches:find?key={GSB_API_KEY}" + body = GSB_req_build(url) + + start = time.time() + try: + response = requests.post(endpoint, json=body, timeout=30) + elapsed = time.time() - start + + try: + response_data = response.json() if response.status_code == 200 else None + except ValueError: + response_data = None + + return { + "type": STATUS_SUCCESS, + "status_code": response.status_code, + "data": response_data, + "elapsed": elapsed + } + except requests.exceptions.Timeout: + return {"type": STATUS_TIMEOUT, "data": None} + except requests.exceptions.RequestException: + return {"type": STATUS_REQUEST_FAILED, "data": None} + +def GSB_req_build(url: str) -> dict: + return { + "client": {"clientId": "url-analysis-query", "clientVersion": "1.0"}, + "threatInfo": { + "threatTypes": ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION"], + "platformTypes": ["ANY_PLATFORM"], + "threatEntryTypes": ["URL"], + "threatEntries": [{"url": url}] + } + } + +def parse_GSB_result(raw: dict) -> dict: + rtype = raw.get("type") + + if rtype != STATUS_SUCCESS: + return {"is_threat": False, "status_type": [None, rtype], "result_type": False, "threat_labels": []} + + status_code = raw.get("status_code") + if status_code != 200: + return {"is_threat": False, "status_type": [status_code, STATUS_CLIENT_ERROR], "result_type": False, "threat_labels": []} + + data = raw.get("data") or {} + matches = data.get("matches", []) + + if not matches: + return {"is_threat": False, "status_type": [200, STATUS_SUCCESS], "result_type": True, "threat_labels": []} + + threat_types = list(set(m["threatType"] for m in matches)) + labels = [f"{GSB_CODE}:{t}" for t in threat_types] + + return {"is_threat": True, "status_type": [200, STATUS_SUCCESS], "result_type": True, "threat_labels": labels} + +# ========================================== +# 4. OTX 전용 로직 (AlienVault OTX) +# ========================================== +def OTX_req_api(url: str) -> dict: + if not url: return {"type": STATUS_INVALID_INPUT, "data": None} + if not OTX_API_KEY: return {"type": STATUS_MISSING_API_KEY, "data": None} + + endpoint, headers = OTX_req_build(url) + + try: + response = requests.get(endpoint, headers=headers, timeout=30) + try: + response_data = response.json() if response.status_code == 200 else None + except ValueError: + response_data = None + + return {"type": STATUS_SUCCESS, "status_code": response.status_code, "data": response_data} + except requests.exceptions.Timeout: + return {"type": STATUS_TIMEOUT, "data": None} + except requests.exceptions.RequestException: + return {"type": STATUS_REQUEST_FAILED, "data": None} + +def OTX_req_build(url: str) -> tuple[str, dict]: + encoded_url = quote(url, safe='') + endpoint = f"https://otx.alienvault.com/api/v1/indicators/url/{encoded_url}/general" + headers = {"X-OTX-API-KEY": OTX_API_KEY} + return endpoint, headers + +def parse_OTX_result(raw: dict) -> tuple[int, list]: + if raw.get("type") != STATUS_SUCCESS: + return 0, [] + + data = raw.get("data") or {} + pulse_info = data.get("pulse_info", {}) + count = pulse_info.get("count", 0) + + otx_labels = set() # 중복 방지를 위해 set 사용 + + if count > 0: + pulses = pulse_info.get("pulses", []) + + # 실무에서 추출하고자 하는 핵심 위협 키워드 정의 + # 이 리스트에 포함된 태그만 걸러내어 라벨로 만듭니다. + TARGET_THREAT_KEYWORDS = ["PHISHING", "MALWARE", "RANSOMWARE", "BOTNET", "SPAM", "C2"] + + for pulse in pulses: + tags = pulse.get("tags", []) + for tag in tags: + tag_upper = str(tag).upper() + + # 태그가 핵심 키워드 중 하나라면 "OTX:키워드" 형태로 추가 + if tag_upper in TARGET_THREAT_KEYWORDS: + otx_labels.add(f"OTX:{tag_upper}") + + # 만약 신고 내역(count > 0)은 있는데 쓸만한 태그가 단 하나도 없었다면? + # 기본 라벨을 부여하여 위협임을 명시합니다. + if not otx_labels: + otx_labels.add("OTX:SUSPICIOUS") + + return count, list(otx_labels) + +# ========================================== +# 5. WHOIS 전용 로직 (Domain Age) +# ========================================== +def WHOIS_req_api(url: str) -> dict: + if not url: return {"type": STATUS_INVALID_INPUT, "data": None} + + try: + ext = tldextract.extract(url) + domain = f"{ext.domain}.{ext.suffix}" + domain_info = whois.whois(domain) + return {"type": STATUS_SUCCESS, "data": domain_info} + except Exception: + return {"type": STATUS_REQUEST_FAILED, "data": None} + +def parse_WHOIS_result(raw: dict) -> str | None: + if raw.get("type") != STATUS_SUCCESS: + return None + + data = raw.get("data") + if not data: + return None + + # python-whois 객체 특성(dict와 object 혼용) 방어 로직 + creation_date = data.get("creation_date") if isinstance(data, dict) else getattr(data, "creation_date", None) + + if isinstance(creation_date, list): + creation_date = creation_date[0] + + if isinstance(creation_date, datetime): + return creation_date.strftime("%Y-%m-%d") + + return None \ No newline at end of file diff --git a/analyzers/req_server/__init__.py b/analyzers/req_server/__init__.py new file mode 100644 index 0000000..14ae7c0 --- /dev/null +++ b/analyzers/req_server/__init__.py @@ -0,0 +1,10 @@ +from .certificate import build_server_result, cert_req_api, parse_cert_result, parse_issuer +from .req_server import req_server + +__all__ = [ + "build_server_result", + "cert_req_api", + "parse_cert_result", + "parse_issuer", + "req_server", +] diff --git a/analyzers/req_server/certificate.py b/analyzers/req_server/certificate.py new file mode 100644 index 0000000..01b0fa0 --- /dev/null +++ b/analyzers/req_server/certificate.py @@ -0,0 +1,151 @@ +import os +import socket +import ssl +import tempfile + +from .constants import ( + CERT_TIMEOUT, + STATUS_INVALID_INPUT, + STATUS_NO_CERTIFICATE, + STATUS_REQUEST_FAILED, + STATUS_SSL_ERROR, + STATUS_SUCCESS, + STATUS_TIMEOUT, + STATUS_UNEXPECTED_ERROR, + STATUS_VERIFY_FAILED, +) +from .url_utils import normalize_hostname, normalize_port + + +def cert_req_api(hostname: str | None, port: int | str | None = 443) -> dict: + hostname = normalize_hostname(hostname) + port = normalize_port(port) + + if not hostname or not port: + return {"type": STATUS_INVALID_INPUT} + + try: + ssl_context = ssl.create_default_context() + + with socket.create_connection((hostname, port), timeout=CERT_TIMEOUT) as sock: + sock.settimeout(CERT_TIMEOUT) + with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: + ssock.settimeout(CERT_TIMEOUT) + cert = ssock.getpeercert() + + return {"type": STATUS_SUCCESS, "data": cert} + + except (socket.timeout, TimeoutError): + return {"type": STATUS_TIMEOUT} + + except ssl.SSLCertVerificationError as e: + return { + "type": STATUS_VERIFY_FAILED, + "error": str(e), + "data": fetch_cert_without_verification(hostname, port), + } + + except ssl.SSLError as e: + return {"type": STATUS_SSL_ERROR, "error": str(e)} + + except OSError as e: + return {"type": STATUS_REQUEST_FAILED, "error": str(e)} + + except Exception as e: + return {"type": STATUS_UNEXPECTED_ERROR, "error": str(e)} + + +def parse_cert_result(raw: dict) -> dict: + if not isinstance(raw, dict): + return build_cert_failure(STATUS_UNEXPECTED_ERROR, "invalid certificate response") + + rtype = raw.get("type") or STATUS_UNEXPECTED_ERROR + + if rtype != STATUS_SUCCESS: + return build_cert_failure(rtype, raw.get("error"), cert=raw.get("data") or {}) + + cert = raw.get("data") or {} + if not cert: + return build_cert_failure(STATUS_NO_CERTIFICATE, "peer certificate not available") + + return { + "valid": True, + "issuer": parse_issuer(cert.get("issuer")), + "validFrom": cert.get("notBefore"), + "validTo": cert.get("notAfter"), + "status_type": [STATUS_SUCCESS], + "error": [], + } + + +def build_cert_failure( + status_type: str, + error: Exception | str | None = None, + *, + cert: dict | None = None, +) -> dict: + cert = cert or {} + return { + "valid": False, + "issuer": parse_issuer(cert.get("issuer")), + "validFrom": cert.get("notBefore"), + "validTo": cert.get("notAfter"), + "status_type": [status_type], + "error": [str(error)] if error else [], + } + + +def build_server_result(parsed: dict) -> dict: + result = { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": None, + "error": [], + } + result.update(parsed) + return result + + +def fetch_cert_without_verification(hostname: str, port: int) -> dict | None: + try: + ssl_context = ssl._create_unverified_context() + + with socket.create_connection((hostname, port), timeout=CERT_TIMEOUT) as sock: + sock.settimeout(CERT_TIMEOUT) + with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: + ssock.settimeout(CERT_TIMEOUT) + der_cert = ssock.getpeercert(binary_form=True) + + if not der_cert: + return None + + pem_cert = ssl.DER_cert_to_PEM_cert(der_cert) + with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as cert_file: + cert_file.write(pem_cert) + cert_path = cert_file.name + + try: + return ssl._ssl._test_decode_cert(cert_path) + finally: + os.unlink(cert_path) + + except Exception: + return None + + +def parse_issuer(issuer_tuple) -> str | None: + if not issuer_tuple: + return None + + flat = {} + for group in issuer_tuple: + if not isinstance(group, (list, tuple)): + continue + for item in group: + if not isinstance(item, (list, tuple)) or len(item) < 2: + continue + flat[str(item[0])] = str(item[1]) + + return flat.get("organizationName") or flat.get("commonName") diff --git a/analyzers/req_server/constants.py b/analyzers/req_server/constants.py new file mode 100644 index 0000000..e026dd3 --- /dev/null +++ b/analyzers/req_server/constants.py @@ -0,0 +1,14 @@ +STATUS_SUCCESS = "success" +STATUS_TIMEOUT = "timeout" +STATUS_SSL_ERROR = "ssl_error" +STATUS_VERIFY_FAILED = "verify_failed" +STATUS_REQUEST_FAILED = "request_failed" +STATUS_INVALID_INPUT = "invalid_input" +STATUS_UNEXPECTED_ERROR = "unexpected_error" +STATUS_NO_CERTIFICATE = "no_certificate" + +CERT_TIMEOUT = 5 +SERVER_INFO_TIMEOUT = 3 +UNKNOWN_SERVER_INFO = "unknown" +UNKNOWN_LOCATION_INFO = "위치 정보 없음" +USER_AGENT = "veri-q-analysis-engine/1.0" diff --git a/analyzers/req_server/location.py b/analyzers/req_server/location.py new file mode 100644 index 0000000..c8b383a --- /dev/null +++ b/analyzers/req_server/location.py @@ -0,0 +1,70 @@ +COUNTRY_NAMES_KO = { + "KR": "대한민국", + "US": "미국", + "JP": "일본", + "CN": "중국", + "GB": "영국", + "DE": "독일", + "FR": "프랑스", + "CA": "캐나다", + "AU": "호주", + "SG": "싱가포르", + "HK": "홍콩", + "TW": "대만", +} + +KR_REGION_NAMES_KO = { + "11": "서울", + "26": "부산", + "27": "대구", + "28": "인천", + "29": "광주", + "30": "대전", + "31": "울산", + "36": "세종", + "41": "경기도", + "42": "강원도", + "43": "충청북도", + "44": "충청남도", + "45": "전라북도", + "46": "전라남도", + "47": "경상북도", + "48": "경상남도", + "49": "제주도", +} + +US_REGION_NAMES_KO = { + "CA": "캘리포니아주", + "IA": "아이오와주", + "NY": "뉴욕주", + "TX": "텍사스주", + "VA": "버지니아주", + "WA": "워싱턴주", +} + + +def format_location_ko(data: dict) -> str: + country_code = data.get("countryCode") + region_code = data.get("region") + country = COUNTRY_NAMES_KO.get(country_code, data.get("country")) + region = format_region_ko(country_code, region_code, data.get("regionName")) + city = data.get("city") + + parts = [] + for part in (country, region, city): + if part and part not in parts: + parts.append(part) + + return " ".join(parts) + + +def format_region_ko( + country_code: str | None, + region_code: str | None, + region_name: str | None, +) -> str | None: + if country_code == "KR": + return KR_REGION_NAMES_KO.get(region_code, region_name) + if country_code == "US": + return US_REGION_NAMES_KO.get(region_code, region_name) + return region_name diff --git a/analyzers/req_server/req_server.py b/analyzers/req_server/req_server.py new file mode 100644 index 0000000..3e3ee08 --- /dev/null +++ b/analyzers/req_server/req_server.py @@ -0,0 +1,31 @@ +from core.context import AnalysisContext + +from .certificate import ( + build_cert_failure, + build_server_result, + cert_req_api, + parse_cert_result, +) +from .constants import STATUS_UNEXPECTED_ERROR +from .server_info import update_context_server_info +from .url_utils import parse_target_host + + +def req_server(context: AnalysisContext) -> dict: + hostname = context.url.get("hostname") + port = context.url.get("port") + + if not hostname: + hostname, fallback_port = parse_target_host(context) + port = port or fallback_port + + port = port or 443 + update_context_server_info(context, hostname, port) + + try: + raw = cert_req_api(hostname, port) + parsed = parse_cert_result(raw) + except Exception as e: + parsed = build_cert_failure(STATUS_UNEXPECTED_ERROR, e) + + return build_server_result(parsed) diff --git a/analyzers/req_server/server_info.py b/analyzers/req_server/server_info.py new file mode 100644 index 0000000..1450dc7 --- /dev/null +++ b/analyzers/req_server/server_info.py @@ -0,0 +1,92 @@ +import socket + +import requests + +from .constants import ( + SERVER_INFO_TIMEOUT, + UNKNOWN_LOCATION_INFO, + UNKNOWN_SERVER_INFO, + USER_AGENT, +) +from .location import format_location_ko +from .url_utils import build_probe_urls, normalize_hostname + +try: + requests.packages.urllib3.disable_warnings() +except AttributeError: + pass + + +def update_context_server_info(context, hostname: str | None, port: int | str | None) -> None: + if not isinstance(getattr(context, "external", None), dict): + context.external = {} + + context.external["server_type"] = fetch_server_type(context, hostname, port) + context.external["server_location"] = fetch_server_location(hostname) + + +def fetch_server_type(context, hostname: str | None, port: int | str | None) -> str: + hostname = normalize_hostname(hostname) + if not hostname: + return UNKNOWN_SERVER_INFO + + for url in build_probe_urls(context, hostname, port): + try: + response = requests.head( + url, + allow_redirects=True, + headers={"User-Agent": USER_AGENT}, + timeout=SERVER_INFO_TIMEOUT, + verify=False, + ) + server_type = response.headers.get("Server") + response.close() + + if server_type: + return server_type.strip() + + except requests.exceptions.RequestException: + continue + + return UNKNOWN_SERVER_INFO + + +def fetch_server_location(hostname: str | None) -> str: + hostname = normalize_hostname(hostname) + if not hostname: + return UNKNOWN_LOCATION_INFO + + ip_address = resolve_ip_address(hostname) + if not ip_address: + return UNKNOWN_LOCATION_INFO + + try: + response = requests.get( + f"http://ip-api.com/json/{ip_address}", + params={"fields": "status,country,countryCode,region,regionName,city,query"}, + timeout=SERVER_INFO_TIMEOUT, + ) + data = response.json() + response.close() + + if data.get("status") == "success": + return format_location_ko(data) or ip_address + + except (requests.exceptions.RequestException, ValueError): + pass + + return ip_address + + +def resolve_ip_address(hostname: str) -> str | None: + try: + address_info = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except OSError: + return None + + for family in (socket.AF_INET, socket.AF_INET6): + for info in address_info: + if info[0] == family: + return info[4][0] + + return None diff --git a/analyzers/req_server/url_utils.py b/analyzers/req_server/url_utils.py new file mode 100644 index 0000000..6cb5d83 --- /dev/null +++ b/analyzers/req_server/url_utils.py @@ -0,0 +1,115 @@ +from urllib.parse import urlsplit + + +def parse_target_host(context) -> tuple[str | None, int | None]: + candidate_urls = [ + context.url.get("normalized_url"), + context.request.get("original_url"), + ] + + for candidate_url in candidate_urls: + hostname, port = parse_host_from_url(candidate_url) + if hostname: + return hostname, port + + return None, None + + +def parse_host_from_url(url: str | None) -> tuple[str | None, int | None]: + if not isinstance(url, str): + return None, None + + candidate = url.strip() + if not candidate: + return None, None + + split_result = urlsplit(candidate) + if not split_result.hostname: + split_result = urlsplit(f"https://{candidate}") + + try: + port = split_result.port + except ValueError: + port = None + + return split_result.hostname, port + + +def build_probe_urls(context, hostname: str, port: int | str | None) -> list[str]: + urls = [] + + for candidate_url in [ + context.url.get("normalized_url"), + context.request.get("original_url"), + ]: + url = normalize_probe_url(candidate_url) + candidate_hostname, _ = parse_host_from_url(url) + + if normalize_hostname(candidate_hostname) == hostname: + urls.append(url) + + netloc = build_netloc(hostname, port) + scheme = context.url.get("scheme") + if scheme in ("http", "https"): + urls.append(f"{scheme}://{netloc}") + + urls.append(f"https://{netloc}") + urls.append(f"http://{netloc}") + + return unique_values(urls) + + +def normalize_probe_url(url: str | None) -> str | None: + if not isinstance(url, str): + return None + + candidate = url.strip() + if not candidate: + return None + + if not urlsplit(candidate).hostname: + candidate = f"https://{candidate}" + + return candidate + + +def build_netloc(hostname: str, port: int | str | None) -> str: + normalized_port = normalize_port(port) + if normalized_port and normalized_port not in (80, 443): + return f"{hostname}:{normalized_port}" + return hostname + + +def normalize_hostname(hostname: str | None) -> str | None: + if not isinstance(hostname, str): + return None + + normalized = hostname.strip().strip("[]").rstrip(".").lower() + if not normalized: + return None + + try: + return normalized.encode("idna").decode("ascii") + except UnicodeError: + return None + + +def normalize_port(port: int | str | None) -> int | None: + try: + normalized = int(port or 443) + except (TypeError, ValueError): + return None + + if 1 <= normalized <= 65535: + return normalized + return None + + +def unique_values(values: list[str | None]) -> list[str]: + unique = [] + + for value in values: + if value and value not in unique: + unique.append(value) + + return unique diff --git a/analyzers/url_shorten_checker.py b/analyzers/url_shorten_checker.py new file mode 100644 index 0000000..69e974d --- /dev/null +++ b/analyzers/url_shorten_checker.py @@ -0,0 +1,72 @@ +# 단축 URL 여부 검사 +# 정규화된 Hostname을 받아 단축 URL 서비스 도메인과 일치 시, 단축 URL로 판별합니다. +from core.context import AnalysisContext + + +# 메인 함수 +# hostname이 단축 URL 서비스 도메인인지 검사 +def url_shorten_checker(context: AnalysisContext) -> dict: + + hostname = context.url.get("hostname") + return build_shorten_result(hostname) + + +# 실 검사 로직 +def check_shorten_domain(hostname: str | None) -> dict: + if not hostname: + return { + "detected": False, + "matched": "" + } + + if hostname in SHORTENER_DOMAINS: + return { + "detected": True, + "matched": hostname + } + + return { + "detected": False, + "matched": "" + } + + +# 결과 조립 +def build_shorten_result(hostname: str | None) -> dict: + result = { + "detected": False, + "evidence": [], + "details": { + "matched_shortener": "" + } + } + + check = check_shorten_domain(hostname) + + if check["detected"]: + result["detected"] = True + result["evidence"].append(check["matched"]) + result["details"]["matched_shortener"] = check["matched"] + + return result + + +# 단축 URL 서비스 도메인 목록 +SHORTENER_DOMAINS = { + "bit.ly", + "tinyurl.com", + "t.co", + "goo.gl", + "ow.ly", + "is.gd", + "buff.ly", + "adf.ly", + "bit.do", + "cutt.ly", + "url.kr", + "c11.kr", + "han.gl", + "me2.do", + "vo.la", + "lc.chat", +} \ No newline at end of file diff --git a/core/context.py b/core/context.py new file mode 100644 index 0000000..2f29e94 --- /dev/null +++ b/core/context.py @@ -0,0 +1,101 @@ +# 검사의 내용을 실시간 기록하는 페이지입니다. +# 공유 노트 -> 검사 시 필요한 데이터는 여기에 임시 저장, 호출하는 것을 규약으로 합니다. +# 검사 요청 수신 시, 반드시 create_initial_context(scan_id, original_url)를 호출해 context를 생성합니다. +from dataclasses import dataclass, field +from typing import Any, Dict +# flags 삭제, info에 단계 및 기타 정보 일괄 저장하는 걸로. +https: dict = field(default_factory=lambda: { + "isSecure": False + }) + +@dataclass +class AnalysisContext: # key : gusetUuid, originalURL (request에만 저장됨) + request: Dict[str, Any] = field(default_factory=dict) # 수신한 요청 + url: Dict[str, Any] = field(default_factory=dict) # 정규화 거친 url + redirect: Dict[str, Any] = field(default_factory=dict) # 리다이렉트 추적 결과 + detection: Dict[str, Any] = field(default_factory=dict) # 룰 기반 검사기+단축 url + ml: Dict[str, Any] = field(default_factory=dict) # ml검사결과+ml점수 + external: Dict[str, Any] = field(default_factory=dict) # safe browsing/whois api + certificate: Dict[str, Any] = field(default_factory=dict) # 인증서 조회 + scoring: Dict[str, Any] = field(default_factory=dict) # 스코어링 결과 저장 + meta: Dict[str, Any] = field(default_factory=dict) # 실행 단계, 내부 errors 또는 기타 info + + +def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContext: + return AnalysisContext( + request={ + "guest_Uuid": guest_Uuid, + "original_url": original_url + }, + url={ + "normalized_url": None, + "scheme": None, + "hostname": None, + "port": None, + "path": None, + "query": None, + "fragment": None, + "is_https": False, + "has_explicit_scheme": False, #-> 좀 더 쉽게 변경예정 + "is_default_port": False, + }, + redirect={ + "redirect_count": 0, + "final_url": None, + "redirect_chain": [], + "loop_detected": False, + "status_type": [], #status code, 상태 상수 + "result": str # str로 success/fail 저장 + }, + detection={ + "shortened_url": { + "detected": False, + "evidence": [] + }, + "rule_based": { + "flags": [], + "suspicious_query_params": [], + "embedded_urls": [], + "suspicious_keywords": { + "path": [], + "query": [], + "fragment": [] + }, + } + }, + ml = { + "score": None, + "threats": [] + }, + scoring={ + "score": None, + "risk_level": None, + "reason_summary": [], + "matched_rules": [], + }, + external={ + "safe_browsing": { + "is_threat": False, + "status_type": None, + "result_type": None, + "threat_labels": [] + }, + "report_count": None, + "block_count": None, + "domain_age": None + }, + certificate = { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": None, + "error": [] + }, + meta={ + "status": "initialized", + "completed_steps": [], + "warnings": [], + "errors": [], + } + ) \ No newline at end of file diff --git a/core/pipeline.py b/core/pipeline.py new file mode 100644 index 0000000..fbb5c2f --- /dev/null +++ b/core/pipeline.py @@ -0,0 +1,83 @@ +# 선행 검사 결과에 따라 변동되는 검사 과정 세트를 지정합니다. +from datetime import datetime # [추가] + +from analyzers import ( + parameter_rule as rule, + redirect_tracker as track, + url_shorten_checker as short, + ai_analyze as ai, + req_api as api, + req_server as server +) +from services import builder, scoring, url_utils as utils +from services import response as callback + +# [추가] 공통 progress 함수 +def _send_progress(context, step: str, status: str, message: str): + guest_Uuid = context.request.get("guest_Uuid") + callback.send_progress(guest_Uuid, step, status, message) + + + +# [추가] 공통 최종 JSON 생성 +def _build_final_json(context) -> dict: + result = builder.build_result(context) + return builder.build_JSON(result) + + +def run_pipeline(context): + _send_progress(context, "TEST", "IN_PROGRESS", "파이프라인 진입 성공") + + # 단축 URL 검사 전에 hostname만 먼저 확보 + raw_url = context.request.get("original_url") + cleaned_url = utils.sanitize_input_url(raw_url) + parsed_result = utils.parse_url_components(cleaned_url) + + if not parsed_result.get("hostname"): + parsed_result = utils.parse_url_components(f"https://{cleaned_url}") + + context.url["sanitized_url"] = cleaned_url + context.url["hostname"] = (parsed_result.get("hostname") or "").lower() + + _send_progress(context, "SHORT_URL_CHECK", "IN_PROGRESS", "단축 URL 여부를 확인하고 있습니다.") + short_result = short.url_shorten_checker(context) + context.detection["shortened_url"] = short_result + _send_progress(context, "SHORT_URL_CHECK", "COMPLETED", "단축 URL 여부 확인이 완료되었습니다.") + + if context.detection["shortened_url"].get("detected", False): + _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 경로를 추적하고 있습니다.") + context.redirect = track.redirect_tracker(context) + _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 추적이 완료되었습니다.") + + _send_progress(context, "URL_NORMALIZE", "IN_PROGRESS", "URL을 정규화하고 있습니다.") + utils.normalize_url(context) + _send_progress(context, "URL_NORMALIZE", "COMPLETED", "URL 정규화가 완료되었습니다.") + + _send_progress(context, "API_REQ", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") + context.external = api.req_api(context) + context.certificate = server.req_server(context) + _send_progress(context, "API_REQ", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") + + safe_browsing = context.external.get("safe_browsing", {}) + if safe_browsing.get("is_threat"): + context.scoring = scoring.score_analysis(context) + return _build_final_json(context) + + _send_progress(context, "RULE_ANALYSIS", "IN_PROGRESS", "도메인 및 파라미터 규칙 분석을 진행하고 있습니다.") + context.detection["rule_based"] = rule.parameter_rule(context) + _send_progress(context, "RULE_ANALYSIS", "COMPLETED", "도메인 및 파라미터 규칙 분석이 완료되었습니다.") + + _send_progress(context, "ML_ANALYSIS", "IN_PROGRESS", "ML 기반 위험 분석을 진행하고 있습니다.") + context.ml = ai.ai_analyze(context) + _send_progress(context, "ML_ANALYSIS", "COMPLETED", "ML 기반 위험 분석이 완료되었습니다.") + + if not context.detection.get("shortened_url", {}).get("detected", False): + _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 경로를 추적하고 있습니다.") + context.redirect = track.redirect_tracker(context) + _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 추적이 완료되었습니다.") + + _send_progress(context, "SCORING", "IN_PROGRESS", "최종 위험도를 계산하고 있습니다.") + context.scoring = scoring.score_analysis(context) + _send_progress(context, "SCORING", "COMPLETED", "최종 위험도 계산이 완료되었습니다.") + + return _build_final_json(context) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..147ee65 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +services: + # 1. MySQL 데이터베이스 + veriq-db: + image: mysql:8.0 + container_name: veriq-db + restart: always + ports: + - "127.0.0.1:3306:3306" # 외부 IP 접속 차단, 로컬 호스트만 허용 + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: veriq_db + volumes: + - ./mysql_data:/var/lib/mysql + + # 2. Redis 캐시 서버 + veriq-redis: + image: redis:latest + container_name: veriq-redis + restart: always + ports: + - "127.0.0.1:6379:6379" + + # 3. Spring BE1 (Gateway) + veriq-gateway: + build: + context: . + dockerfile: Dockerfile + container_name: veriq-be1 + restart: always + ports: + - "8081:8081" + environment: + SPRING_PROFILES_ACTIVE: be1 + BE3_API_URL: "http://veriq-platform:8083/api/v1/scan/upload" # 도커 내부망 주소로 덮어쓰기 + depends_on: + - veriq-db + - veriq-redis + + # 4. Spring BE3 (Platform/Data Hub) + veriq-platform: + build: + context: . + dockerfile: Dockerfile + container_name: veriq-be3 + restart: always + ports: + - "8083:8083" + environment: + SPRING_PROFILES_ACTIVE: be3 + depends_on: + - veriq-db + - veriq-redis + + # 5. FastAPI (분석 엔진) + veriq-analysis: + build: + context: ../analysis-engine + dockerfile: Dockerfile + container_name: veriq-analysis + restart: always + ports: + - "8000:8000" + depends_on: + - veriq-db diff --git a/main.py b/main.py new file mode 100644 index 0000000..fd638e6 --- /dev/null +++ b/main.py @@ -0,0 +1,33 @@ +# main interface for analysis +from datetime import datetime +from fastapi import FastAPI + +from core.context import create_initial_context +from core.pipeline import run_pipeline +from models.receive import AnalysisRequest +from services import response as callback + + +app = FastAPI() + + +@app.get("/") +def root(): + print("connected") + return {"message": "server is running"} + + +@app.post("/analyze") +def analyze(req: AnalysisRequest): + context = create_initial_context(req.guest_Uuid, req.url) + print("context created") + context.meta["scanned_at"] = datetime.now().isoformat() + + print("start pipeline") + result = run_pipeline(context) + + print("posting final result...") + guest_Uuid = context.request.get("guest_Uuid") + callback.send_final_result(guest_Uuid, result) + + return {"message": "analyze done", "result": result} diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..daa1999 --- /dev/null +++ b/models/base.py @@ -0,0 +1,13 @@ +# 전체 검사기의 기본 구조 및 호출 형식을 통일하는 기반입니다. +# 입력: context, 결과: 각 검사 종료 후 context에 직접 기록(반환X) +from core.context import AnalysisContext + +def run_detector(detector, context: AnalysisContext): + try: + detector(context) + context.meta["completed_steps"].append(detector.__name__) + except Exception as e: + context.meta["errors"].append({ + "step": detector.__name__, + "error": str(e) + }) \ No newline at end of file diff --git a/models/receive.py b/models/receive.py new file mode 100644 index 0000000..3d70f7e --- /dev/null +++ b/models/receive.py @@ -0,0 +1,11 @@ +# url 분석 요청 시 수신하는 데이터의 형식을 정의합니다. +from pydantic import BaseModel, Field + + +class AnalysisRequest(BaseModel): + # [수정] BE3에서 넘겨주는 식별자 기준으로 변경 + guest_Uuid: str = Field(alias="guestUuid") + url: str + + class Config: + populate_by_name = True \ No newline at end of file diff --git a/models/result.py b/models/result.py new file mode 100644 index 0000000..f3ad017 --- /dev/null +++ b/models/result.py @@ -0,0 +1,52 @@ +# 최종 응답 데이터. 즉, JSON 작성을 위한 데이터 구조 +from datetime import datetime, timezone +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + + +@dataclass +class AnalysisResult: + guestUuid: str + analysisTime: str + originalUrl: str + + https: dict = field(default_factory=lambda: { + "isSecure": False + }) + + shortUrl: dict = field(default_factory=lambda: { + "isShortened": False + }) + + ml: dict = field(default_factory=lambda: { + "score": 0, + "threats": [] + }) + + externalApi: dict = field(default_factory=lambda: { + "checked": False, + "provider": None, + "result": None + }) + + redirect: dict = field(default_factory=lambda: { + "finalUrl": None, + "redirectCount": 0 + }) + + threats: dict = field(default_factory=lambda: { + }) + + serverInfo: dict = field(default_factory=lambda: { + "type": None, + "location": None, + "certificate": { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None + } + }) + + score: int = 0 + riskLevel: str = "SAFE" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7918bae --- /dev/null +++ b/requirements.txt @@ -0,0 +1,19 @@ +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.13.0 +click==8.3.2 +colorama==0.4.6 +fastapi==0.135.3 +h11==0.16.0 +idna==3.11 +pydantic==2.12.5 +pydantic_core==2.41.5 +python-dotenv==1.2.2 +starlette==1.0.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +uvicorn==0.44.0 +pymysql==1.1.2 +python-whois==0.9.6 +requests==2.32.3 +tldextract==5.3.1 diff --git a/services/builder.py b/services/builder.py new file mode 100644 index 0000000..b0ec5db --- /dev/null +++ b/services/builder.py @@ -0,0 +1,106 @@ +# 검사 결과를 정리하고, 전송할 수 있는 형태로 build합니다. +from datetime import datetime +from core.context import AnalysisContext +from models.result import AnalysisResult + + +def build_threats(context: AnalysisContext) -> list[str]: + labels = [] + + if context.detection.get("shortened_url", {}).get("detected"): + labels.append("shortened_url") + + for flag in context.detection.get("rule_based", {}).get("flags", []): + if flag not in labels: + labels.append(flag) + + for label in context.external.get("safe_browsing", {}).get("threat_labels", []): + if label not in labels: + labels.append(label) + + for error in context.certificate.get("error", []): + if error not in labels: + labels.append(error) + + return labels + + +def build_external_result(context: AnalysisContext) -> str | None: + safe_browsing = context.external.get("safe_browsing", {}) + + status_type = safe_browsing.get("status_type") + result_type = safe_browsing.get("result_type") + + status = status_type[1] if isinstance(status_type, (list, tuple)) and len(status_type) > 1 else status_type + + if status in ("timeout", "client_error", "server_error", "request_failed"): + return status_type + if result_type is not None: + return str(result_type) + if safe_browsing.get("is_threat"): + return "THREAT" + if status_type is not None: + return "SAFE" + return None + + +def build_result(context: AnalysisContext) -> AnalysisResult: + safe_browsing = context.external.get("safe_browsing", {}) + + result = AnalysisResult( + guestUuid = context.request.get("guest_Uuid", {}), + analysisTime=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), + originalUrl=context.url.get("original_url") or "" + ) + + result.https["isSecure"] = bool(context.url.get("is_https", False)) + + result.shortUrl["isShortened"] = bool( + context.detection.get("shortened_url", {}).get("detected", False) + ) + + result.threats = build_threats(context) + result.ml = context.ml or {} + + result.externalApi["checked"] = ( + safe_browsing.get("status_type") is not None + or safe_browsing.get("result_type") is not None + ) + result.externalApi["provider"] = "Google Safe Browsing" + result.externalApi["result"] = build_external_result(context) + + result.redirect["finalUrl"] = ( + context.redirect.get("final_url") + or context.url.get("normalized_url") + or context.url.get("original_url") + ) + result.redirect["redirectCount"] = int(context.redirect.get("redirect_count", 0) or 0) + + result.serverInfo["type"] = context.external.get("server_type") + result.serverInfo["location"] = context.external.get("server_location") + result.serverInfo["certificate"]["valid"] = context.certificate.get("valid") + result.serverInfo["certificate"]["issuer"] = context.certificate.get("issuer") + result.serverInfo["certificate"]["validFrom"] = context.certificate.get("validFrom") + result.serverInfo["certificate"]["validTo"] = context.certificate.get("validTo") + + result.score = int(context.scoring.get("score", 0) or 0) + result.riskLevel = context.scoring.get("risk_level") or "SAFE" + + return result + + +def build_JSON(result: AnalysisResult) -> dict: + return { + "guestUuid": result.guestUuid, + "analysisTime": result.analysisTime, + "originalUrl": result.originalUrl, + "https": result.https, + "shortUrl": result.shortUrl, + "threats": result.threats, + "ml": result.ml, + "externalApi": result.externalApi, + "redirect": result.redirect, + "serverInfo": result.serverInfo, + "score": result.score, + "riskLevel": result.riskLevel + } \ No newline at end of file diff --git a/services/response.py b/services/response.py new file mode 100644 index 0000000..531c047 --- /dev/null +++ b/services/response.py @@ -0,0 +1,88 @@ +# BE3으로 전송하는 부분 +import os +import requests + +# [추가] 환경변수 +ML_SECRET_KEY = os.getenv("ML_SECRET_KEY") +CALLBACK_PROGRESS_URL = os.getenv("BE3_CALLBACK_PROGRESS_URL") +CALLBACK_FINAL_URL = os.getenv("BE3_CALLBACK_FINAL_URL") + +TIMEOUT_SECONDS = 5 + + +def _build_headers(guest_Uuid: str) -> dict: + return { + "X-ML-Secret": ML_SECRET_KEY or "", + "Content-Type": "application/json", + "guest_uuid": guest_Uuid, + } + + +def _post_json(url: str | None, guest_Uuid: str, payload: dict) -> dict: + # URL 없으면 → 실패 JSON 생성 후 종료 + if not url: + payload.update({ + "status": "ERROR", + "message": "callback_url_missing" + }) + print("post_json: callback url missing") + return False + + if not ML_SECRET_KEY: + payload.update({ + "status": "ERROR", + "message": "ml_secret_missing" + }) + print("post_json: ml key missing") + return False + + try: + response = requests.post( + url, + json=payload, + headers=_build_headers(guest_Uuid), + timeout=TIMEOUT_SECONDS + ) + print("post_json: progress response") + + # 실패 시에도 payload에 반영 + if not response.ok: + payload.update({ + "status": "ERROR", + "message": f"http_{response.status_code}" + }) + print("post_json: https error") + return False + return payload + + except requests.exceptions.Timeout: + payload.update({ + "status": "ERROR", + "message": "timeout" + }) + print("post_json: timeout") + return False + + except requests.exceptions.RequestException as e: + payload.update({ + "status": "ERROR", + "message": str(e) + }) + print("post_json: ERROR") + return False + + +# [추가] 중간보고 +def send_progress(guest_Uuid: str, step: str, status: str, message: str) -> dict: + payload = { + "guestUuid": guest_Uuid, + "step": step, + "status": status, + "message": message + } + return _post_json(CALLBACK_PROGRESS_URL, guest_Uuid, payload) + + +# [추가] 최종보고 +def send_final_result(guest_Uuid: str, result_json: dict) -> dict: + return _post_json(CALLBACK_FINAL_URL, guest_Uuid, result_json) \ No newline at end of file diff --git a/services/scoring.py b/services/scoring.py new file mode 100644 index 0000000..46ce3dc --- /dev/null +++ b/services/scoring.py @@ -0,0 +1,196 @@ +# 검사 결과에 기반해 점수를 매기는 로직입니다. +from core.context import AnalysisContext + + +def score_analysis(context: AnalysisContext) -> dict: + internal_score, internal_reasons = score_internal(context) + ml_score = score_ml(context) + external_score, external_reasons = score_external(context) + + final_score = round( + internal_score * 0.45 + + ml_score * 0.35 + + external_score * 0.20 + ) + + # Safe Browsing 위험이면 강제 threat + safe_browsing = context.external.get("safe_browsing", {}) + if safe_browsing.get("is_threat"): + final_score = max(final_score, 85) + + final_score = max(0, min(100, final_score)) + risk_level = decide_risk_level(final_score, safe_browsing) + + return build_scoring_result( + internal_score=internal_score, + ml_score=ml_score, + external_score=external_score, + score=final_score, + risk_level=risk_level, + reason_summary=internal_reasons + external_reasons, + matched_rules=collect_matched_rules(context) + ) + + +def score_internal(context: AnalysisContext) -> tuple[int, list]: + score = 0 + reasons = [] + + shortened = context.detection.get("shortened_url", {}) + if shortened.get("detected"): + score += 15 + reasons.append("shortened_url") + + redirect = context.redirect + redirect_count = redirect.get("redirect_count", 0) + + if redirect_count == 1: + score += 5 + reasons.append("redirect_1") + elif redirect_count == 2: + score += 10 + reasons.append("redirect_2") + elif redirect_count >= 3: + score += 15 + reasons.append("redirect_3_or_more") + + if redirect.get("loop_detected"): + score += 30 + reasons.append("redirect_loop_detected") + + status_type = redirect.get("status_type") + if status_type: + _, label = status_type + if label == "too_many_redirects": + score += 25 + reasons.append("too_many_redirects") + + rule_based = context.detection.get("rule_based", {}) + flags = rule_based.get("flags", []) + + flag_scores = { + "suspicious_query_param_detected": 12, + "embedded_url": 15, + "suspicious_query_keyword_detected": 8, + "suspicious_path_keyword_detected": 8, + "suspicious_fragment_keyword_detected": 8, + "percent_encoding_detected": 8, + "double_encoding_suspected": 15, + } + + for flag in flags: + score += flag_scores.get(flag, 0) + reasons.append(flag) + + return min(score, 100), reasons + + +def score_ml(context: AnalysisContext) -> int: + score = context.ml.get("score") + + try: + score = int(round(score)) + except Exception: + score = 0 + + return max(0, min(100, score)) + + +def score_external(context: AnalysisContext) -> tuple[int, list]: + score = 0 + reasons = [] + + external = context.external + + report_count = external.get("report_count") + if isinstance(report_count, int): + if 1 <= report_count <= 2: + score += 10 + reasons.append("report_count_low") + elif 3 <= report_count <= 5: + score += 20 + reasons.append("report_count_mid") + elif report_count >= 6: + score += 30 + reasons.append("report_count_high") + + block_count = external.get("block_count") + if isinstance(block_count, int): + if 1 <= block_count <= 2: + score += 10 + reasons.append("block_count_low") + elif block_count >= 3: + score += 20 + reasons.append("block_count_high") + + cert_valid = context.certificate.get("valid") + if cert_valid is False: + score += 25 + reasons.append("invalid_certificate") + + domain_age = external.get("domain_age") + if domain_age in ("new", "very_new"): + score += 15 + reasons.append("young_domain") + + server_type = external.get("server_type") + if server_type in (None, "", "unknown"): + score += 10 + reasons.append("unknown_server_type") + + return min(score, 100), reasons + + +def decide_risk_level(final_score: int, safe_browsing: dict) -> str: + if safe_browsing.get("is_threat"): + return "threat" + + if final_score >= 60: + return "threat" + if final_score >= 30: + return "caution" + return "safe" + + +def collect_matched_rules(context: AnalysisContext) -> list: + matched = [] + + shortened = context.detection.get("shortened_url", {}) + if shortened.get("detected"): + matched.append("shortened_url") + + rule_based = context.detection.get("rule_based", {}) + matched.extend(rule_based.get("flags", [])) + + safe_browsing = context.external.get("safe_browsing", {}) + matched.extend(safe_browsing.get("threat_labels", [])) + + ml_result = context.ml or {} + matched.extend(ml_result.get("threats", [])) + + # 중복 제거 + unique = [] + for item in matched: + if item not in unique: + unique.append(item) + + return unique + + +def build_scoring_result(**overrides) -> dict: + result = { + "internal_score": 0, + "ml_score": 0, + "external_score": 0, + "score": 0, + "risk_level": None, + "reason_summary": [], + "matched_rules": [] + } + + for key in overrides: + if key not in result: + raise KeyError(f"허용되지 않은 scoring 필드: {key}") + + result.update(overrides) + return result \ No newline at end of file diff --git a/services/url_utils.py b/services/url_utils.py new file mode 100644 index 0000000..af85b62 --- /dev/null +++ b/services/url_utils.py @@ -0,0 +1,144 @@ +# URL을 분해하고, 정규화하고, 필요하다면 관련 다른 기능들도 제공합니다. +from urllib.parse import urlsplit, urlunsplit +from core.context import AnalysisContext + + +#입력 URL의 앞뒤 공백/탭/개행 제거 +def sanitize_input_url(raw_url: str) -> str: + if raw_url is None: + raise ValueError("URL 입력값이 None입니다.") + + cleaned_url = raw_url.strip() + if not cleaned_url: + raise ValueError("URL 입력값이 비어 있습니다.") + + return cleaned_url + + +#URL을 구성 요소별로 분해 +def parse_url_components(url: str) -> dict: + + split_result = urlsplit(url) # urlsplit() 결과 객체 + + scheme = split_result.scheme # URL scheme + netloc = split_result.netloc # 도메인+포트 + hostname = split_result.hostname # 순수 도메인 + port = split_result.port # 포트 번호 + path = split_result.path # 경로 + query = split_result.query # 쿼리 문자열 + fragment = split_result.fragment # fragment + + return { + "scheme": scheme, + "netloc": netloc, + "hostname": hostname, + "port": port, + "path": path, + "query": query, + "fragment": fragment + } #parsed_result + + +#URL에 scheme(http,https 등)이 존재하는지 확인 +#없을 시 https 부착 **검사 진행 위한 구조 정상화 목적** +#소문자화 scheme, scheme 존재 여부, https 여부를 반환합니다. +def normalize_scheme(parsed_result:dict): + scheme = parsed_result["scheme"] # scheme + scheme = scheme.lower() # 소문자로 저장 + scheme_exists = bool(scheme) # 보정 전 scheme 존재 여부 + is_https = (scheme == "https") # https 여부 (보정 전 기준) + + #scheme 미존재시 검사용으로 https 부착 + if scheme_exists is False: + scheme = "https" + + return { + "normalized_scheme": scheme, + "had_scheme_originally": scheme_exists, # 원본 scheme 존재 여부 + "is_https": is_https # 원본 url이 https였는지 여부 + } + + +#hostname 소문자화, 기본 포트 여부 판단 +def normalize_url_components(parsed_result: dict, normalized_scheme: str): + hostname = parsed_result["hostname"] # hostname + port = parsed_result["port"] # port + is_default_port = False # 기본 포트 여부(http:80/https:443) + + if hostname != None: + hostname = hostname.lower() + else: + hostname = "" + + # 기본 포트 여부 확인(http/https의 경우만) + if normalized_scheme == "http" and port == 80: + is_default_port = True + port = None + elif normalized_scheme == "https" and port == 443: + is_default_port = True + port = None + + return { + "normalized_hostname": hostname, + "normalized_port": port, # 기본 포트 제거 반영 + "is_default_port": is_default_port, # 기본 포트 여부 + } #normalized_comp + + +#URL 재구성 +def rebuild_normalized_url(parsed_result: dict, normalized_scheme: str, normalized_comp: dict): + # hostname+port + if normalized_comp["normalized_port"] is None: + netloc = normalized_comp["normalized_hostname"] + else: + netloc = f"{normalized_comp['normalized_hostname']}:{normalized_comp['normalized_port']}" + + # normalized url (output) + integrated_url = urlunsplit(( + normalized_scheme, + netloc, + parsed_result["path"], + parsed_result["query"], + parsed_result["fragment"])) + + return { + "netloc": netloc, + "normalized_url": integrated_url + } + + +# context.url에 정규화 결과를 기록하는 메인 함수 +def normalize_url(context: AnalysisContext) -> None: + # 단축 URL이면 리다이렉트 후 최종 URL을 사용, 아니면 원본 URL 사용 + if context.detection["shortened_url"]["detected"]: + target_url = context.redirect.get("final_url") + else: + target_url = context.request.get("original_url") + + cleaned_url = sanitize_input_url(target_url) + parsed_result = parse_url_components(cleaned_url) + scheme_result = normalize_scheme(parsed_result) + normalized_comp = normalize_url_components( + parsed_result, + scheme_result["normalized_scheme"] + ) + rebuilt_result = rebuild_normalized_url( + parsed_result, + scheme_result["normalized_scheme"], + normalized_comp + ) + + # context.url에 최종 결과 기록 + context.url["normalized_url"] = rebuilt_result["normalized_url"] + context.url["scheme"] = scheme_result["normalized_scheme"] + context.url["hostname"] = normalized_comp["normalized_hostname"] + context.url["port"] = normalized_comp["normalized_port"] + context.url["path"] = parsed_result["path"] + context.url["query"] = parsed_result["query"] + context.url["fragment"] = parsed_result["fragment"] + context.url["is_https"] = scheme_result["is_https"] + context.url["has_explicit_scheme"] = scheme_result["had_scheme_originally"] + context.url["is_default_port"] = normalized_comp["is_default_port"] + + # 정규화 수행 완료 표시 + context.meta["normalized"] = True From 7bcbe90cfebd89c036d80c2dd47a9e70a631befc Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 1 May 2026 11:52:34 +0900 Subject: [PATCH 57/77] =?UTF-8?q?safe=5Fbrowsing=20=EB=AA=A8=EB=93=88,=20e?= =?UTF-8?q?nv(=ED=82=A4),=20api=5Ftest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/safe_browsing/__init__.py | 19 ++++++ analyzers/safe_browsing/exceptions.py | 28 ++++++++ analyzers/safe_browsing/google_api.py | 92 +++++++++++++++++++++++++++ analyzers/safe_browsing/schemas.py | 31 +++++++++ test_api.py | 18 ++++++ 5 files changed, 188 insertions(+) create mode 100644 analyzers/safe_browsing/__init__.py create mode 100644 analyzers/safe_browsing/exceptions.py create mode 100644 analyzers/safe_browsing/google_api.py create mode 100644 analyzers/safe_browsing/schemas.py create mode 100644 test_api.py diff --git a/analyzers/safe_browsing/__init__.py b/analyzers/safe_browsing/__init__.py new file mode 100644 index 0000000..f5a5e21 --- /dev/null +++ b/analyzers/safe_browsing/__init__.py @@ -0,0 +1,19 @@ +# analyzers/external_analysis/__init__.py + +from .google_api import GoogleSafeBrowsing +from .schemas import SafeBrowsingResponse + +# 1. 인스턴스를 미리 생성하여 '싱글톤'처럼 활용합니다. +# 이렇게 하면 호출할 때마다 매번 객체를 생성할 필요가 없어 효율적입니다. +_analyzer = GoogleSafeBrowsing() + +# 2. 외부(main.py)에서 사용할 공식 함수를 정의합니다. +async def check_url_safety(url: str) -> dict: + """ + URL의 안전성을 검사하여 BE3가 설계한 DB 규격에 맞는 결과를 반환합니다. + 반환 필드: apiChecked, apiProvider, apiResult + """ + return await _analyzer.analyze(url) + +# 3. 'from external_analysis import *' 를 했을 때 노출될 항목을 제한합니다. (캡슐화) +__all__ = ["check_url_safety"] \ No newline at end of file diff --git a/analyzers/safe_browsing/exceptions.py b/analyzers/safe_browsing/exceptions.py new file mode 100644 index 0000000..834d2f7 --- /dev/null +++ b/analyzers/safe_browsing/exceptions.py @@ -0,0 +1,28 @@ +# analyzers/external_analysis/exceptions.py + +class SafeBrowsingError(Exception): + """구글 세이프 브라우징 모듈의 기본 예외 클래스입니다.""" + def __init__(self, message="분석 중 오류가 발생했습니다.", status_code=500): + self.message = message + self.status_code = status_code + super().__init__(self.message) + +class InvalidAPIKeyError(SafeBrowsingError): + """API 키가 잘못되었거나 인증에 실패했을 때 발생합니다.""" + def __init__(self, message="Google API 키 인증에 실패했습니다."): + super().__init__(message, status_code=401) + +class QuotaExceededError(SafeBrowsingError): + """API 호출 할당량을 초과했을 때 발생합니다.""" + def __init__(self, message="API 호출 할당량을 초과했습니다. 무료 티어 확인이 필요합니다."): + super().__init__(message, status_code=429) + +class AnalysisTimeoutError(SafeBrowsingError): + """외부 API 응답이 너무 늦어 타임아웃이 발생했을 때 사용합니다.""" + def __init__(self, message="Google API 응답 시간이 초과되었습니다."): + super().__init__(message, status_code=504) + +class ServiceUnavailableError(SafeBrowsingError): + """구글 서버 자체에 문제가 있어 응답을 받을 수 없을 때 발생합니다.""" + def __init__(self, message="현재 Google 분석 서비스를 사용할 수 없습니다."): + super().__init__(message, status_code=503) \ No newline at end of file diff --git a/analyzers/safe_browsing/google_api.py b/analyzers/safe_browsing/google_api.py new file mode 100644 index 0000000..8376af7 --- /dev/null +++ b/analyzers/safe_browsing/google_api.py @@ -0,0 +1,92 @@ +import httpx +import os +import logging +from dotenv import load_dotenv + +# .env 파일에서 환경 변수 로드 +load_dotenv() + +# 로그 설정 (운영 환경에서의 트래킹을 위함) +logger = logging.getLogger(__name__) + +class GoogleSafeBrowsing: + def __init__(self): + self.api_key = os.getenv("GOOGLE_SAFE_BROWSING_KEY") + self.api_url = f"https://safebrowsing.googleapis.com/v4/threatMatches:find?key={self.api_key}" + self.provider_name = "Google Safe Browsing" + + async def analyze(self, target_url: str) -> dict: + """ + URL을 분석하여 DB 엔티티 구조에 맞는 결과를 반환합니다. + """ + # API 키가 없을 경우의 방어 로직 + if not self.api_key: + logger.error("Google API Key가 설정되지 않았습니다.") + return { + "apiChecked": False, + "apiProvider": self.provider_name, + "apiResult": "CONFIG_ERROR" + } + + # Google API 요청 페이로드 구성 + payload = { + "client": { + "clientId": "veri-q-backend", + "clientVersion": "1.0.0" + }, + "threatInfo": { + "threatTypes": ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION"], + "platformTypes": ["ANY_PLATFORM"], + "threatEntryTypes": ["URL"], + "threatEntries": [{"url": target_url}] + } + } + + try: + # 비동기 HTTP 클라이언트를 사용한 호출 + async with httpx.AsyncClient() as client: + response = await client.post( + self.api_url, + json=payload, + timeout=5.0 # 외부 API 지연으로 인한 서버 중단 방지 + ) + + if response.status_code == 200: + result_data = response.json() + # Google API는 위협 발견 시 'matches' 리스트를 반환함 + if "matches" in result_data: + threat_type = result_data["matches"][0]["threatType"] + return { + "apiChecked": True, + "apiProvider": self.provider_name, + "apiResult": f"DANGER: {threat_type}" + } + else: + return { + "apiChecked": True, + "apiProvider": self.provider_name, + "apiResult": "SAFE" + } + + # 200 OK가 아닌 경우 (할당량 초과, 잘못된 요청 등) + logger.warning(f"Google API Error: {response.status_code}") + return { + "apiChecked": False, + "apiProvider": self.provider_name, + "apiResult": f"API_ERROR_{response.status_code}" + } + + except httpx.TimeoutException: + logger.error("Google API 호출 타임아웃 발생") + return { + "apiChecked": False, + "apiProvider": self.provider_name, + "apiResult": "TIMEOUT" + } + except Exception as e: + logger.error(f"Google API 분석 중 예기치 못한 에러: {str(e)}") + return { + "apiChecked": False, + "apiProvider": self.provider_name, + "apiResult": "INTERNAL_EXCEPTION" + } diff --git a/analyzers/safe_browsing/schemas.py b/analyzers/safe_browsing/schemas.py new file mode 100644 index 0000000..b3e4c41 --- /dev/null +++ b/analyzers/safe_browsing/schemas.py @@ -0,0 +1,31 @@ +# analyzers/external_analysis/schemas.py + +from pydantic import BaseModel, Field + +class SafeBrowsingResult(BaseModel): + """ + Google Safe Browsing 분석 결과를 담는 스키마입니다. + BE3가 설계한 DB 엔티티(ExternalApiInfo) 구조와 1:1 매핑됩니다. + """ + apiChecked: bool = Field( + ..., + description="외부 API 조회 성공 여부" + ) + apiProvider: str = Field( + default="Google Safe Browsing", + description="API 제공자 이름" + ) + apiResult: str = Field( + ..., + description="분석 결과 메시지 (예: SAFE, DANGER: MALWARE 등)" + ) + + class Config: + # JSON 결과 예시를 명시하여 Swagger 문서화를 돕습니다. + schema_extra = { + "example": { + "apiChecked": True, + "apiProvider": "Google Safe Browsing", + "apiResult": "SAFE" + } + } \ No newline at end of file diff --git a/test_api.py b/test_api.py new file mode 100644 index 0000000..3197b34 --- /dev/null +++ b/test_api.py @@ -0,0 +1,18 @@ +import asyncio +from analyzers.external_analysis import check_url_safety + +async def run_test(): + # 1. 안전한 URL 테스트 + print("--- Test 1: Safe URL ---") + safe_url = "https://www.google.com" + result_safe = await check_url_safety(safe_url) + print(f"Result: {result_safe}\n") + + # 2. 위험한 URL 테스트 (구글 제공 테스트용 악성 URL) + print("--- Test 2: Dangerous URL ---") + malware_url = "http://testsafebrowsing.appspot.com/s/malware.html" + result_danger = await check_url_safety(malware_url) + print(f"Result: {result_danger}") + +if __name__ == "__main__": + asyncio.run(run_test()) \ No newline at end of file From 597349c2a36b3ab29469855e70999c89f9f5d7a5 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 1 May 2026 12:08:28 +0900 Subject: [PATCH 58/77] =?UTF-8?q?*analyzers/req=5Fserver/url=5Futils=20con?= =?UTF-8?q?text=EC=A7=81=EC=A0=91=20=EC=A0=91=EA=B7=BC=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9A=A9=20=EC=9E=84=EC=8B=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_server/url_utils.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/analyzers/req_server/url_utils.py b/analyzers/req_server/url_utils.py index 6cb5d83..c77034a 100644 --- a/analyzers/req_server/url_utils.py +++ b/analyzers/req_server/url_utils.py @@ -2,9 +2,12 @@ def parse_target_host(context) -> tuple[str | None, int | None]: + norm_url = context.url.get("normalized_url"), + origin_url = context.request.get("original_url"), + candidate_urls = [ - context.url.get("normalized_url"), - context.request.get("original_url"), + norm_url, + origin_url ] for candidate_url in candidate_urls: @@ -37,10 +40,12 @@ def parse_host_from_url(url: str | None) -> tuple[str | None, int | None]: def build_probe_urls(context, hostname: str, port: int | str | None) -> list[str]: urls = [] + norm_url = context.url.get("normalized_url"), + origin_url = context.request.get("original_url"), for candidate_url in [ - context.url.get("normalized_url"), - context.request.get("original_url"), + norm_url, + origin_url ]: url = normalize_probe_url(candidate_url) candidate_hostname, _ = parse_host_from_url(url) From 10e6c91427d7d6734108065f46cb5194759cfe9e Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 1 May 2026 12:24:49 +0900 Subject: [PATCH 59/77] =?UTF-8?q?*builder=EC=9D=B4=20request=EC=97=90?= =?UTF-8?q?=EC=84=9C=20original=5Furl=EC=9D=84=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/builder.py b/services/builder.py index b0ec5db..cf130aa 100644 --- a/services/builder.py +++ b/services/builder.py @@ -50,7 +50,7 @@ def build_result(context: AnalysisContext) -> AnalysisResult: result = AnalysisResult( guestUuid = context.request.get("guest_Uuid", {}), analysisTime=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), - originalUrl=context.url.get("original_url") or "" + originalUrl=context.request.get("original_url") or "" ) result.https["isSecure"] = bool(context.url.get("is_https", False)) From 1c97d47d9a5804ddfe1bc93b313e1895b20c9bf4 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 1 May 2026 13:00:02 +0900 Subject: [PATCH 60/77] =?UTF-8?q?-=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/gradle.xml | 14 ++++++++++++++ .idea/misc.xml | 4 +--- .idea/modules.xml | 8 -------- analysis-engine-develop/.idea/.gitignore | 10 ++++++++++ analysis-engine-develop/.idea/misc.xml | 6 ++++++ analyzers/safe_browsing/__init__.py | 2 +- 6 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 .idea/gradle.xml delete mode 100644 .idea/modules.xml create mode 100644 analysis-engine-develop/.idea/.gitignore create mode 100644 analysis-engine-develop/.idea/misc.xml diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..dfedd11 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,14 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 6f29fee..6ed36dd 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,6 +1,4 @@ - - - + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 203c8f1..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/analysis-engine-develop/.idea/.gitignore b/analysis-engine-develop/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/analysis-engine-develop/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/analysis-engine-develop/.idea/misc.xml b/analysis-engine-develop/.idea/misc.xml new file mode 100644 index 0000000..6f29fee --- /dev/null +++ b/analysis-engine-develop/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/analyzers/safe_browsing/__init__.py b/analyzers/safe_browsing/__init__.py index f5a5e21..350f4ba 100644 --- a/analyzers/safe_browsing/__init__.py +++ b/analyzers/safe_browsing/__init__.py @@ -1,7 +1,7 @@ # analyzers/external_analysis/__init__.py from .google_api import GoogleSafeBrowsing -from .schemas import SafeBrowsingResponse +from .schemas import SafeBrowsingResult # 1. 인스턴스를 미리 생성하여 '싱글톤'처럼 활용합니다. # 이렇게 하면 호출할 때마다 매번 객체를 생성할 필요가 없어 효율적입니다. From dba683560630678604dc72f8a6be2bd165c04cf5 Mon Sep 17 00:00:00 2001 From: parkgogeun Date: Fri, 1 May 2026 13:00:24 +0900 Subject: [PATCH 61/77] =?UTF-8?q?-=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test_api.py b/test_api.py index 3197b34..8afa28b 100644 --- a/test_api.py +++ b/test_api.py @@ -1,5 +1,5 @@ import asyncio -from analyzers.external_analysis import check_url_safety +from analyzers.safe_browsing import check_url_safety async def run_test(): # 1. 안전한 URL 테스트 From babe7a8ec53bc50204bbd738f24cb9ab642fa744 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sun, 3 May 2026 00:22:25 +0900 Subject: [PATCH 62/77] =?UTF-8?q?*=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20i?= =?UTF-8?q?de=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++-- .idea/.gitignore | 10 ---------- .idea/analysis-engine-develop.iml | 9 --------- .idea/gradle.xml | 14 -------------- .idea/misc.xml | 4 ---- .idea/vcs.xml | 6 ------ analysis-engine-develop/.idea/.gitignore | 10 ---------- analysis-engine-develop/.idea/misc.xml | 6 ------ 8 files changed, 2 insertions(+), 61 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/analysis-engine-develop.iml delete mode 100644 .idea/gradle.xml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/vcs.xml delete mode 100644 analysis-engine-develop/.idea/.gitignore delete mode 100644 analysis-engine-develop/.idea/misc.xml diff --git a/.gitignore b/.gitignore index 0cfe250..961db9d 100644 --- a/.gitignore +++ b/.gitignore @@ -174,7 +174,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. @@ -187,7 +187,7 @@ cython_debug/ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore # and can be added to the global gitignore or merged into this file. However, if you prefer, # you could uncomment the following to ignore the entire vscode folder -# .vscode/ +.vscode/ # Ruff stuff: .ruff_cache/ diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index ab1f416..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/analysis-engine-develop.iml b/.idea/analysis-engine-develop.iml deleted file mode 100644 index d6ebd48..0000000 --- a/.idea/analysis-engine-develop.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index dfedd11..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 6ed36dd..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/analysis-engine-develop/.idea/.gitignore b/analysis-engine-develop/.idea/.gitignore deleted file mode 100644 index ab1f416..0000000 --- a/analysis-engine-develop/.idea/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Ignored default folder with query files -/queries/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/analysis-engine-develop/.idea/misc.xml b/analysis-engine-develop/.idea/misc.xml deleted file mode 100644 index 6f29fee..0000000 --- a/analysis-engine-develop/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From e99f3f9e8834b92b6ca2cf3f6c92bf53711cf3d1 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sun, 3 May 2026 08:34:33 +0900 Subject: [PATCH 63/77] =?UTF-8?q?*1=EC=B0=A8=20=EC=9E=84=EC=8B=9C:=20featu?= =?UTF-8?q?re=20=EC=B6=94=EC=B6=9C=EA=B8=B0=20=EA=B2=B0=ED=95=A9=EC=9D=84?= =?UTF-8?q?=20=EC=9C=84=ED=95=9C=20=EC=A0=95=EB=A6=AC=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=A4=91=EC=9E=85=EB=8B=88=EB=8B=A4.=20**=EC=A0=84=EB=B0=98?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=95=88=EC=A0=95=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EA=B0=9C=EC=84=A0=ED=95=98=EB=8A=94=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81**=20=EB=B3=80=EA=B2=BD=EC=A0=901:?= =?UTF-8?q?=20google=20safe=20browsing=20=EC=A4=91=EB=B3=B5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20api=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=A0=84=EB=B0=98=EC=9D=98=20api=EB=B3=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC/=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=A0=902:=20config=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=83=81=EC=88=98=20=EA=B4=80=EB=A6=AC-?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EC=A4=91,=20=ED=98=84=EC=9E=AC?= =?UTF-8?q?=EB=8A=94=20status=EC=99=80=20context=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=EA=B0=80=EB=8A=A5=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EC=A0=903:=20req=5Fserver=EC=97=90=EC=84=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=84=9C=20=EC=A1=B0=ED=9A=8C=EB=B6=80=EB=A7=8C=20ana?= =?UTF-8?q?lyzers/req=5Fcert.py=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=A0=904:=20safe=20browsing=EC=97=90=EC=84=9C=20thre?= =?UTF-8?q?at=ED=8C=90=EC=A0=95=20=EC=8B=9C=20pipeline=20=EC=A4=91?= =?UTF-8?q?=EA=B0=84=20=ED=83=88=EC=B6=9C=20=EC=A0=9C=EC=99=B8-=20ml=20fea?= =?UTF-8?q?ture=20=EC=88=98=EC=A7=91=EC=9D=B4=20=EB=B3=B4=EC=9E=A5?= =?UTF-8?q?=EB=90=98=EB=A0=A4=EB=A9=B4=20=EC=A0=84=EC=B2=B4=20=EB=B6=84?= =?UTF-8?q?=EC=84=9D=EC=9D=84=20=EA=B0=80=EB=8A=A5=ED=95=9C=20=EB=8F=8C?= =?UTF-8?q?=EA=B2=8C=20=ED=95=B4=EC=95=BC=20=ED=96=88=EC=9D=8C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=A0=905:=20req=5Fserver/url=5Futils.py=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=84=20server=5Furl=5Futils=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD.=20=EC=82=AC=EC=9C=A0-=20service/url=5Futils?= =?UTF-8?q?.py=EC=99=80=20=ED=98=BC=EB=8F=99=20=EA=B0=80=EB=8A=A5=EC=84=B1?= =?UTF-8?q?=EC=9D=B4=20=EB=84=88=EB=AC=B4=20=ED=81=BC.=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=A0=906:=20=20scoring=EC=9D=B4=20otx=EC=99=80=20who?= =?UTF-8?q?is=EC=9D=98=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=EC=A0=907:=20builder=EA=B0=80=20otx?= =?UTF-8?q?=EC=9D=98=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=ED=95=98=EA=B2=8C=20=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/__init__.py | 0 analyzers/external/__init__.py | 0 analyzers/external/req_gsb.py | 126 +++++++++ analyzers/external/req_otx.py | 111 ++++++++ analyzers/external/req_whois.py | 71 +++++ analyzers/req_api.py | 252 ++---------------- analyzers/req_cert.py | 145 ++++++++++ analyzers/req_server/certificate.py | 151 ----------- analyzers/req_server/req_server.py | 8 +- analyzers/req_server/server_info.py | 2 +- .../{url_utils.py => server_url_utils.py} | 8 +- core/__init__.py | 0 core/config.py | 102 +++++++ core/context.py | 18 +- core/pipeline.py | 48 ++-- models/__init__.py | 0 models/result.py | 3 +- services/__init__.py | 0 services/builder.py | 99 ++++--- services/response.py | 37 +-- services/scoring.py | 92 ++++--- services/url_utils.py | 17 +- {analyzers => test}/api_test.py | 0 test_api.py => test/test_api.py | 0 24 files changed, 776 insertions(+), 514 deletions(-) create mode 100644 analyzers/__init__.py create mode 100644 analyzers/external/__init__.py create mode 100644 analyzers/external/req_gsb.py create mode 100644 analyzers/external/req_otx.py create mode 100644 analyzers/external/req_whois.py create mode 100644 analyzers/req_cert.py delete mode 100644 analyzers/req_server/certificate.py rename analyzers/req_server/{url_utils.py => server_url_utils.py} (92%) create mode 100644 core/__init__.py create mode 100644 core/config.py create mode 100644 models/__init__.py create mode 100644 services/__init__.py rename {analyzers => test}/api_test.py (100%) rename test_api.py => test/test_api.py (100%) diff --git a/analyzers/__init__.py b/analyzers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analyzers/external/__init__.py b/analyzers/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/analyzers/external/req_gsb.py b/analyzers/external/req_gsb.py new file mode 100644 index 0000000..87069f7 --- /dev/null +++ b/analyzers/external/req_gsb.py @@ -0,0 +1,126 @@ +import os +import time +import requests + +from core.config import statusData as stat + +GSB_KEY = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY") +GSB_CODE = "GSB" + + +# GSB 조회 메인 함수 +def req_gsb(target_url: str | None) -> dict: + raw_gsb = GSB_req_api(target_url) + parsed_gsb = parse_GSB_result(raw_gsb) + + return parsed_gsb + + +# GSB request +def GSB_req_api(url: str | None) -> dict: + if not url: + return {"status": stat.INVALID_INPUT, "data": None} + + if not GSB_KEY: + return {"status": stat.MISSING_API_KEY, "data": None} + + endpoint = f"https://safebrowsing.googleapis.com/v4/threatMatches:find?key={GSB_KEY}" + body = GSB_req_build(url) + + start = time.time() + + try: + response = requests.post(endpoint, json=body, timeout=30) + elapsed = time.time() - start + + try: + response_data = response.json() if response.status_code == 200 else None + except ValueError: + response_data = None + + return { + "status": stat.SUCCESS, + "status_code": response.status_code, + "data": response_data, + "elapsed": elapsed + } + + except requests.exceptions.Timeout: + return {"status": stat.TIMEOUT, "data": None} + + except requests.exceptions.RequestException as e: + return { + "status": stat.REQUEST_FAILED, + "data": None, + "error": str(e) + } + + +# GSB request body 생성 +def GSB_req_build(url: str) -> dict: + return { + "client": { + "clientId": "url-analysis-query", + "clientVersion": "1.0" + }, + "threatInfo": { + "threatTypes": [ + "MALWARE", + "SOCIAL_ENGINEERING", + "UNWANTED_SOFTWARE", + "POTENTIALLY_HARMFUL_APPLICATION" + ], + "platformTypes": ["ANY_PLATFORM"], + "threatEntryTypes": ["URL"], + "threatEntries": [{"url": url}] + } + } + + +# GSB 응답 파싱 +def parse_GSB_result(raw: dict) -> dict: + status = raw.get("status") + + if status != stat.SUCCESS: + return { + "is_threat": False, + "status_type": [None, status], + "result_type": None, + "threat_labels": [] + } + + status_code = raw.get("status_code") + + if status_code != 200: + return { + "is_threat": False, + "status_type": [status_code, stat.CLIENT_ERROR], + "result_type": None, + "threat_labels": [] + } + + data = raw.get("data") or {} + matches = data.get("matches", []) + + if not matches: + return { + "is_threat": False, + "status_type": [200, stat.SUCCESS], + "result_type": True, + "threat_labels": [] + } + + threat_types = { + match.get("threatType") + for match in matches + if match.get("threatType") + } + + labels = [f"{GSB_CODE}:{threat}" for threat in threat_types] + + return { + "is_threat": True, + "status_type": [200, stat.SUCCESS], + "result_type": True, + "threat_labels": labels + } \ No newline at end of file diff --git a/analyzers/external/req_otx.py b/analyzers/external/req_otx.py new file mode 100644 index 0000000..2b08a38 --- /dev/null +++ b/analyzers/external/req_otx.py @@ -0,0 +1,111 @@ +import os +import requests +from urllib.parse import quote + +from core.config import statusData as stat + +OTX_KEY = os.getenv("OTX_API_KEY") +TARGET_THREAT_KEYWORDS = [ + "PHISHING", + "MALWARE", + "RANSOMWARE", + "BOTNET", + "SPAM", + "C2", +] # 위험 유형 라벨 + + + +# OTX 조회 메인 함수 +def req_otx(target_url: str | None) -> dict: + raw_otx = OTX_req_api(target_url) + pulse_count, threat_labels = parse_OTX_result(raw_otx) + + result = { + "status": raw_otx.get("status"), + "is_threat": pulse_count > 0, + "pulse_count": pulse_count, + "threat_labels": threat_labels, + } + + return result + + +# OTX request +def OTX_req_api(url: str | None) -> dict: + if not url: + return {"status": stat.INVALID_INPUT, "data": None} + + if not OTX_KEY: + return {"status": stat.MISSING_API_KEY, "data": None} + + endpoint, headers = OTX_req_build(url) + + try: + response = requests.get(endpoint, headers=headers, timeout=30) + + try: + response_data = response.json() if response.status_code == 200 else None + except ValueError: + response_data = None + + if response.status_code == 200: + return { + "status": stat.SUCCESS, + "status_code": response.status_code, + "data": response_data, + } + + return { + "status": stat.CLIENT_ERROR, + "status_code": response.status_code, + "data": response_data, + } + + except requests.exceptions.Timeout: + return {"status": stat.TIMEOUT, "data": None} + + except requests.exceptions.RequestException as e: + return { + "status": stat.REQUEST_FAILED, + "data": None, + "error": str(e), + } + + +# OTX request URL/header 생성 +def OTX_req_build(url: str) -> tuple[str, dict]: + encoded_url = quote(url, safe="") + endpoint = f"https://otx.alienvault.com/api/v1/indicators/url/{encoded_url}/general" + headers = {"X-OTX-API-KEY": OTX_KEY} + return endpoint, headers + + +# OTX 응답에서 pulse count와 threat label 추출 +def parse_OTX_result(raw: dict) -> tuple[int, list[str]]: + if raw.get("status") != stat.SUCCESS: + return 0, [] + + data = raw.get("data") or {} + pulse_info = data.get("pulse_info", {}) + count = pulse_info.get("count", 0) or 0 + + otx_labels = set() # 중복 방지 + + if count > 0: + pulses = pulse_info.get("pulses", []) + + for pulse in pulses: + tags = pulse.get("tags", []) + + for tag in tags: + tag_upper = str(tag).upper() + + if tag_upper in TARGET_THREAT_KEYWORDS: + otx_labels.add(f"OTX:{tag_upper}") + + # 신고 내역(count > 0)있음, 쓸만한 태그 없음 -> 기본 라벨 + if not otx_labels: + otx_labels.add("OTX:SUSPICIOUS") + + return count, list(otx_labels) \ No newline at end of file diff --git a/analyzers/external/req_whois.py b/analyzers/external/req_whois.py new file mode 100644 index 0000000..a0d88b9 --- /dev/null +++ b/analyzers/external/req_whois.py @@ -0,0 +1,71 @@ +import whois +import tldextract +from datetime import datetime + +from core.config import statusData as stat + + +# whois 조회 메인 함수 +def req_whois(target_url: str | None) -> dict: + raw_whois = WHOIS_req_api(target_url) + domain_date = parse_WHOIS_result(raw_whois) + + result = { + "status": raw_whois.get("status"), + "creation_date": domain_date, + "domain_age_days": calculate_domain_age(domain_date) + } + + return result + + +# whois request +def WHOIS_req_api(url: str | None) -> dict: + if not url: return {"status": stat.INVALID_INPUT, "data": None} + + try: + ext = tldextract.extract(url) + + if not ext.domain or not ext.suffix: + return {"status": stat.INVALID_INPUT, "data": None} + + domain = f"{ext.domain}.{ext.suffix}" + + domain_info = whois.whois(domain) + return {"status": stat.SUCCESS, "data": domain_info} + except Exception as e: + return {"status": stat.REQUEST_FAILED, "data": None, "error": str(e)} + + +# 도메인의 생성일만 추출 +def parse_WHOIS_result(raw: dict) -> str | None: + if raw.get("status") != stat.SUCCESS: + return None + + data = raw.get("data") + if not data: + return None + + # python-whois 객체 특성(dict와 object 혼용) 방어 로직 + creation_date = data.get("creation_date") if isinstance(data, dict) else getattr(data, "creation_date", None) + + if isinstance(creation_date, list): + creation_date = creation_date[0] if creation_date else None + + if isinstance(creation_date, datetime): + return creation_date.strftime("%Y-%m-%d") + + return None + + +# 검사일 기준 도메인 생성일로부터 경과한 일 수 +def calculate_domain_age(creation_date: str | None) -> int | None: + if not creation_date: + return None + + try: + created = datetime.strptime(creation_date, "%Y-%m-%d").date() + today = datetime.now().date() + return max((today - created).days, 0) # max: 날짜가 미래로 들어오는 오류가 있을 경우 방어용 + except ValueError: + return None \ No newline at end of file diff --git a/analyzers/req_api.py b/analyzers/req_api.py index 910f4f9..3e04abf 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -1,30 +1,38 @@ # 외부 api 조회 -import os -import time -import whois +# 파이프라인에서는 해당 파일만 호출합니다. import tldextract -import requests -from urllib.parse import quote -from datetime import datetime -from dotenv import load_dotenv +from analyzers.external.req_gsb import req_gsb +from analyzers.external.req_otx import req_otx +from analyzers.external.req_whois import req_whois + from core.context import AnalysisContext -load_dotenv() -GSB_CODE = "GSB" -GSB_API_KEY = os.getenv("GOOGLE_SAFE_BROWSING_API_KEY") -OTX_API_KEY = os.getenv("OTX_API_KEY") -STATUS_SUCCESS = "success" -STATUS_TIMEOUT = "timeout" -STATUS_REQUEST_FAILED = "request_failed" -STATUS_INVALID_INPUT = "invalid_input" -STATUS_CLIENT_ERROR = "client_error" -STATUS_MISSING_API_KEY = "missing_api_key" +# 외부 api 조회 함수 +def req_api(context: AnalysisContext) -> dict: + target_url = context.url.get("normalized_url") + + # [1] GSB 파이프라인 + gsb_result = req_gsb(target_url) + + # [2] OTX 파이프라인 + otx_result = req_otx(target_url) + + # [3] WHOIS 파이프라인 + whois_result = req_whois(target_url) + + # [4] 최종 조립 + return{ + "safe_browsing": gsb_result, + "otx": otx_result, + "whois": whois_result + } + # ========================================== -# 1. 공통 유틸리티 및 화이트리스트 로직 +# 공통 유틸리티 및 화이트리스트 로직 # ========================================== def is_whitelisted(url: str) -> bool: """URL의 도메인이 화이트리스트에 포함되어 있는지 확인""" @@ -54,211 +62,3 @@ def is_whitelisted(url: str) -> bool: return False except Exception: return False - -# ========================================== -# 2. 메인 오케스트레이션 로직 -# ========================================== -def req_api(context: AnalysisContext) -> dict: - target_url = context.url.get("normalized_url") - - # [1] GSB 파이프라인 - raw_gsb = GSB_req_api(target_url) - parsed_gsb_result = parse_GSB_result(raw_gsb) - - # [2] OTX 파이프라인 - report_count = 0 - if not is_whitelisted(target_url): - raw_otx = OTX_req_api(target_url) - - # [수정] count와 동적으로 생성된 라벨 리스트를 함께 받아옵니다. - report_count, otx_labels = parse_OTX_result(raw_otx) - - if report_count > 0: - parsed_gsb_result["is_threat"] = True - - # [수정] 무조건 "OTX:MALWARE"를 넣는 대신, 추출해 낸 라벨들을 순회하며 추가 - for label in otx_labels: - if label not in parsed_gsb_result["threat_labels"]: - parsed_gsb_result["threat_labels"].append(label) - - # [3] WHOIS 파이프라인 - raw_whois = WHOIS_req_api(target_url) - domain_age = parse_WHOIS_result(raw_whois) - - # [4] 최종 조립 - return build_api_result("safe_browsing", parsed_gsb_result, report_count, domain_age) - - -def build_api_result(name: str, parsed_gsb: dict, report_count: int, domain_age: str | None) -> dict: - """모든 파이프라인의 결과를 모아 최종 포맷으로 조립 (기존 구조 복원)""" - base = { - name: { - "is_threat": False, - "status_type": None, - "result_type": None, - "threat_labels": [] - }, - "report_count": report_count, - "block_count": 0, # 내부 DB 미사용 시 0 - "domain_age": domain_age - } - - base[name].update(parsed_gsb) - return base - -# ========================================== -# 3. GSB 전용 로직 (Google Safe Browsing) -# ========================================== -def GSB_req_api(url: str | None) -> dict: - if not url: return {"type": STATUS_INVALID_INPUT, "data": None} - if not GSB_API_KEY: return {"type": STATUS_MISSING_API_KEY, "data": None} - - endpoint = f"https://safebrowsing.googleapis.com/v4/threatMatches:find?key={GSB_API_KEY}" - body = GSB_req_build(url) - - start = time.time() - try: - response = requests.post(endpoint, json=body, timeout=30) - elapsed = time.time() - start - - try: - response_data = response.json() if response.status_code == 200 else None - except ValueError: - response_data = None - - return { - "type": STATUS_SUCCESS, - "status_code": response.status_code, - "data": response_data, - "elapsed": elapsed - } - except requests.exceptions.Timeout: - return {"type": STATUS_TIMEOUT, "data": None} - except requests.exceptions.RequestException: - return {"type": STATUS_REQUEST_FAILED, "data": None} - -def GSB_req_build(url: str) -> dict: - return { - "client": {"clientId": "url-analysis-query", "clientVersion": "1.0"}, - "threatInfo": { - "threatTypes": ["MALWARE", "SOCIAL_ENGINEERING", "UNWANTED_SOFTWARE", "POTENTIALLY_HARMFUL_APPLICATION"], - "platformTypes": ["ANY_PLATFORM"], - "threatEntryTypes": ["URL"], - "threatEntries": [{"url": url}] - } - } - -def parse_GSB_result(raw: dict) -> dict: - rtype = raw.get("type") - - if rtype != STATUS_SUCCESS: - return {"is_threat": False, "status_type": [None, rtype], "result_type": False, "threat_labels": []} - - status_code = raw.get("status_code") - if status_code != 200: - return {"is_threat": False, "status_type": [status_code, STATUS_CLIENT_ERROR], "result_type": False, "threat_labels": []} - - data = raw.get("data") or {} - matches = data.get("matches", []) - - if not matches: - return {"is_threat": False, "status_type": [200, STATUS_SUCCESS], "result_type": True, "threat_labels": []} - - threat_types = list(set(m["threatType"] for m in matches)) - labels = [f"{GSB_CODE}:{t}" for t in threat_types] - - return {"is_threat": True, "status_type": [200, STATUS_SUCCESS], "result_type": True, "threat_labels": labels} - -# ========================================== -# 4. OTX 전용 로직 (AlienVault OTX) -# ========================================== -def OTX_req_api(url: str) -> dict: - if not url: return {"type": STATUS_INVALID_INPUT, "data": None} - if not OTX_API_KEY: return {"type": STATUS_MISSING_API_KEY, "data": None} - - endpoint, headers = OTX_req_build(url) - - try: - response = requests.get(endpoint, headers=headers, timeout=30) - try: - response_data = response.json() if response.status_code == 200 else None - except ValueError: - response_data = None - - return {"type": STATUS_SUCCESS, "status_code": response.status_code, "data": response_data} - except requests.exceptions.Timeout: - return {"type": STATUS_TIMEOUT, "data": None} - except requests.exceptions.RequestException: - return {"type": STATUS_REQUEST_FAILED, "data": None} - -def OTX_req_build(url: str) -> tuple[str, dict]: - encoded_url = quote(url, safe='') - endpoint = f"https://otx.alienvault.com/api/v1/indicators/url/{encoded_url}/general" - headers = {"X-OTX-API-KEY": OTX_API_KEY} - return endpoint, headers - -def parse_OTX_result(raw: dict) -> tuple[int, list]: - if raw.get("type") != STATUS_SUCCESS: - return 0, [] - - data = raw.get("data") or {} - pulse_info = data.get("pulse_info", {}) - count = pulse_info.get("count", 0) - - otx_labels = set() # 중복 방지를 위해 set 사용 - - if count > 0: - pulses = pulse_info.get("pulses", []) - - # 실무에서 추출하고자 하는 핵심 위협 키워드 정의 - # 이 리스트에 포함된 태그만 걸러내어 라벨로 만듭니다. - TARGET_THREAT_KEYWORDS = ["PHISHING", "MALWARE", "RANSOMWARE", "BOTNET", "SPAM", "C2"] - - for pulse in pulses: - tags = pulse.get("tags", []) - for tag in tags: - tag_upper = str(tag).upper() - - # 태그가 핵심 키워드 중 하나라면 "OTX:키워드" 형태로 추가 - if tag_upper in TARGET_THREAT_KEYWORDS: - otx_labels.add(f"OTX:{tag_upper}") - - # 만약 신고 내역(count > 0)은 있는데 쓸만한 태그가 단 하나도 없었다면? - # 기본 라벨을 부여하여 위협임을 명시합니다. - if not otx_labels: - otx_labels.add("OTX:SUSPICIOUS") - - return count, list(otx_labels) - -# ========================================== -# 5. WHOIS 전용 로직 (Domain Age) -# ========================================== -def WHOIS_req_api(url: str) -> dict: - if not url: return {"type": STATUS_INVALID_INPUT, "data": None} - - try: - ext = tldextract.extract(url) - domain = f"{ext.domain}.{ext.suffix}" - domain_info = whois.whois(domain) - return {"type": STATUS_SUCCESS, "data": domain_info} - except Exception: - return {"type": STATUS_REQUEST_FAILED, "data": None} - -def parse_WHOIS_result(raw: dict) -> str | None: - if raw.get("type") != STATUS_SUCCESS: - return None - - data = raw.get("data") - if not data: - return None - - # python-whois 객체 특성(dict와 object 혼용) 방어 로직 - creation_date = data.get("creation_date") if isinstance(data, dict) else getattr(data, "creation_date", None) - - if isinstance(creation_date, list): - creation_date = creation_date[0] - - if isinstance(creation_date, datetime): - return creation_date.strftime("%Y-%m-%d") - - return None \ No newline at end of file diff --git a/analyzers/req_cert.py b/analyzers/req_cert.py new file mode 100644 index 0000000..df9e953 --- /dev/null +++ b/analyzers/req_cert.py @@ -0,0 +1,145 @@ +# 인증서 조회기 분리 +# 서버정보 취득에 실패해도 정상 작동합니다. +import socket +import ssl + +from core.context import AnalysisContext +from core.config import statusData as stat + +CERT_TIMEOUT = 5 + + +def req_cert(context: AnalysisContext) -> dict: + hostname = context.url.get("hostname") + + raw = cert_req_api(hostname) + parsed = parse_cert_result(raw) + + return build_cert_result(parsed) + + +def cert_req_api(hostname: str | None) -> dict: + if not hostname: + return { + "status": stat.INVALID_INPUT, + "error": "hostname_missing" + } + + try: + ssl_context = ssl.create_default_context() + + with socket.create_connection((hostname, 443), timeout=CERT_TIMEOUT) as sock: + with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: + cert = ssock.getpeercert() + + return { + "status": stat.SUCCESS, + "data": cert + } + + except (socket.timeout, TimeoutError): + return { + "status": stat.TIMEOUT, + "error": "certificate_request_timeout" + } + + except ssl.SSLCertVerificationError as e: + return { + "status": stat.VERIFY_FAILED, + "error": str(e) + } + + except ssl.SSLError as e: + return { + "status": stat.SSL_ERROR, + "error": str(e) + } + + except OSError as e: + return { + "status": stat.REQUEST_FAILED, + "error": str(e) + } + + except Exception as e: + return { + "status": stat.UNEXPECTED_ERROR, + "error": str(e) + } + + +def parse_cert_result(raw: dict) -> dict: + if not isinstance(raw, dict): + return build_cert_failure( + stat.UNEXPECTED_ERROR, + "invalid certificate response" + ) + + status = raw.get("status") + + if status != stat.SUCCESS: + return build_cert_failure( + status or stat.UNEXPECTED_ERROR, + raw.get("error") + ) + + cert = raw.get("data") or {} + + if not cert: + return build_cert_failure( + stat.NO_CERTIFICATE, + "peer certificate not available" + ) + + return { + "valid": True, + "issuer": parse_issuer(cert.get("issuer")), + "validFrom": cert.get("notBefore"), + "validTo": cert.get("notAfter"), + "status_type": stat.SUCCESS, + "error": [] + } + + +def build_cert_failure(status: str, error: str | None = None) -> dict: + return { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": status, + "error": [error or status] + } + + +def build_cert_result(parsed: dict) -> dict: + base = { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": None, + "error": [] + } + + base.update(parsed) + return base + + +def parse_issuer(issuer_tuple) -> str | None: + if not issuer_tuple: + return None + + flat = {} + + for group in issuer_tuple: + for key, value in group: + flat[key] = value + + if flat.get("organizationName"): + return flat["organizationName"] + + if flat.get("commonName"): + return flat["commonName"] + + return None \ No newline at end of file diff --git a/analyzers/req_server/certificate.py b/analyzers/req_server/certificate.py deleted file mode 100644 index 01b0fa0..0000000 --- a/analyzers/req_server/certificate.py +++ /dev/null @@ -1,151 +0,0 @@ -import os -import socket -import ssl -import tempfile - -from .constants import ( - CERT_TIMEOUT, - STATUS_INVALID_INPUT, - STATUS_NO_CERTIFICATE, - STATUS_REQUEST_FAILED, - STATUS_SSL_ERROR, - STATUS_SUCCESS, - STATUS_TIMEOUT, - STATUS_UNEXPECTED_ERROR, - STATUS_VERIFY_FAILED, -) -from .url_utils import normalize_hostname, normalize_port - - -def cert_req_api(hostname: str | None, port: int | str | None = 443) -> dict: - hostname = normalize_hostname(hostname) - port = normalize_port(port) - - if not hostname or not port: - return {"type": STATUS_INVALID_INPUT} - - try: - ssl_context = ssl.create_default_context() - - with socket.create_connection((hostname, port), timeout=CERT_TIMEOUT) as sock: - sock.settimeout(CERT_TIMEOUT) - with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: - ssock.settimeout(CERT_TIMEOUT) - cert = ssock.getpeercert() - - return {"type": STATUS_SUCCESS, "data": cert} - - except (socket.timeout, TimeoutError): - return {"type": STATUS_TIMEOUT} - - except ssl.SSLCertVerificationError as e: - return { - "type": STATUS_VERIFY_FAILED, - "error": str(e), - "data": fetch_cert_without_verification(hostname, port), - } - - except ssl.SSLError as e: - return {"type": STATUS_SSL_ERROR, "error": str(e)} - - except OSError as e: - return {"type": STATUS_REQUEST_FAILED, "error": str(e)} - - except Exception as e: - return {"type": STATUS_UNEXPECTED_ERROR, "error": str(e)} - - -def parse_cert_result(raw: dict) -> dict: - if not isinstance(raw, dict): - return build_cert_failure(STATUS_UNEXPECTED_ERROR, "invalid certificate response") - - rtype = raw.get("type") or STATUS_UNEXPECTED_ERROR - - if rtype != STATUS_SUCCESS: - return build_cert_failure(rtype, raw.get("error"), cert=raw.get("data") or {}) - - cert = raw.get("data") or {} - if not cert: - return build_cert_failure(STATUS_NO_CERTIFICATE, "peer certificate not available") - - return { - "valid": True, - "issuer": parse_issuer(cert.get("issuer")), - "validFrom": cert.get("notBefore"), - "validTo": cert.get("notAfter"), - "status_type": [STATUS_SUCCESS], - "error": [], - } - - -def build_cert_failure( - status_type: str, - error: Exception | str | None = None, - *, - cert: dict | None = None, -) -> dict: - cert = cert or {} - return { - "valid": False, - "issuer": parse_issuer(cert.get("issuer")), - "validFrom": cert.get("notBefore"), - "validTo": cert.get("notAfter"), - "status_type": [status_type], - "error": [str(error)] if error else [], - } - - -def build_server_result(parsed: dict) -> dict: - result = { - "valid": False, - "issuer": None, - "validFrom": None, - "validTo": None, - "status_type": None, - "error": [], - } - result.update(parsed) - return result - - -def fetch_cert_without_verification(hostname: str, port: int) -> dict | None: - try: - ssl_context = ssl._create_unverified_context() - - with socket.create_connection((hostname, port), timeout=CERT_TIMEOUT) as sock: - sock.settimeout(CERT_TIMEOUT) - with ssl_context.wrap_socket(sock, server_hostname=hostname) as ssock: - ssock.settimeout(CERT_TIMEOUT) - der_cert = ssock.getpeercert(binary_form=True) - - if not der_cert: - return None - - pem_cert = ssl.DER_cert_to_PEM_cert(der_cert) - with tempfile.NamedTemporaryFile("w", suffix=".pem", delete=False) as cert_file: - cert_file.write(pem_cert) - cert_path = cert_file.name - - try: - return ssl._ssl._test_decode_cert(cert_path) - finally: - os.unlink(cert_path) - - except Exception: - return None - - -def parse_issuer(issuer_tuple) -> str | None: - if not issuer_tuple: - return None - - flat = {} - for group in issuer_tuple: - if not isinstance(group, (list, tuple)): - continue - for item in group: - if not isinstance(item, (list, tuple)) or len(item) < 2: - continue - flat[str(item[0])] = str(item[1]) - - return flat.get("organizationName") or flat.get("commonName") diff --git a/analyzers/req_server/req_server.py b/analyzers/req_server/req_server.py index 3e3ee08..c180253 100644 --- a/analyzers/req_server/req_server.py +++ b/analyzers/req_server/req_server.py @@ -1,14 +1,8 @@ from core.context import AnalysisContext -from .certificate import ( - build_cert_failure, - build_server_result, - cert_req_api, - parse_cert_result, -) from .constants import STATUS_UNEXPECTED_ERROR from .server_info import update_context_server_info -from .url_utils import parse_target_host +from .server_url_utils import parse_target_host def req_server(context: AnalysisContext) -> dict: diff --git a/analyzers/req_server/server_info.py b/analyzers/req_server/server_info.py index 1450dc7..623b3e0 100644 --- a/analyzers/req_server/server_info.py +++ b/analyzers/req_server/server_info.py @@ -9,7 +9,7 @@ USER_AGENT, ) from .location import format_location_ko -from .url_utils import build_probe_urls, normalize_hostname +from .server_url_utils import build_probe_urls, normalize_hostname try: requests.packages.urllib3.disable_warnings() diff --git a/analyzers/req_server/url_utils.py b/analyzers/req_server/server_url_utils.py similarity index 92% rename from analyzers/req_server/url_utils.py rename to analyzers/req_server/server_url_utils.py index c77034a..a96fb92 100644 --- a/analyzers/req_server/url_utils.py +++ b/analyzers/req_server/server_url_utils.py @@ -2,8 +2,8 @@ def parse_target_host(context) -> tuple[str | None, int | None]: - norm_url = context.url.get("normalized_url"), - origin_url = context.request.get("original_url"), + norm_url = context.url.get("normalized_url") + origin_url = context.request.get("original_url") candidate_urls = [ norm_url, @@ -40,8 +40,8 @@ def parse_host_from_url(url: str | None) -> tuple[str | None, int | None]: def build_probe_urls(context, hostname: str, port: int | str | None) -> list[str]: urls = [] - norm_url = context.url.get("normalized_url"), - origin_url = context.request.get("original_url"), + norm_url = context.url.get("normalized_url") + origin_url = context.request.get("original_url") for candidate_url in [ norm_url, diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..5f53f99 --- /dev/null +++ b/core/config.py @@ -0,0 +1,102 @@ +# 오타 방지를 위한 상수화 클래스들을 한 곳에서 관리하는 파일입니다. +# from core.config import (사용할 class명) + + +# json의 result 라벨 +class riskLevelData: + THREAT = "THREAT" + CAUTION = "CAUTION" + SAFE = "SAFE" + + +# 상태 데이터 목록 +class statusData: + SUCCESS = "success" + TIMEOUT = "timeout" + REQUEST_FAILED = "request_failed" + INVALID_INPUT = "invalid_input" + CLIENT_ERROR = "client_error" + MISSING_API_KEY = "missing_api_key" + + NO_CERTIFICATE = "no_certificate" + UNEXPECTED_ERROR = "unexpected_error" + VERIFY_FAILED = "verify_failed" + SSL_ERROR = "ssl_error" + + + +# context 접근 시 필드명 목록 +# !!context 변경 시 반드시 함께 변경해야 합니다!! +class ctxData: + Req_Uuid = "guest_Uuid" + Req_original_url = "original_url" + + Url_normalized_url = "normalized_url" + Url_scheme = "scheme" + Url_hostname = "hostname" + Url_port = "port" + Url_path = "path" + Url_query = "query" + Url_fragment = "fragment" + Url_is_https = "is_https" + Url_def_scheme = "has_explicit_scheme" + Url_def_port = "is_default_port" + + Red_count = "redirect_count" + Red_final_url = "final_url" + Red_chain = "redirect_chain" + Red_loop_det = "loop_detected" + Red_status = "status_type" + Red_result = "result" + + # detection + Det_short = "shortened_url" + Short_det = "detected" + Short_evd = "evidence" + + Det_rule = "rule_based" + Rule_flags = "flags" + Rule_Squery = "suspicious_query_params" + Rule_emedded_url = "embedded_urls" + Rule_Skeywords = "suspicious_keywords" + + # ml + Ml_score = "score" + Ml_threats = "threats" + + # scoring + Sco_score = "score" + Sco_riskLv = "risk_level" + Sco_reason = "reason_summary" + Sco_matched = "matched_rules" + + # external + EX_GSB = "safe_browsing" + GSB_is_threat = "is_threat" + GSB_status = "status_type" + GSB_result = "result_type" + GSB_threats = "threat_labels" + + EX_OTX = "otx" + OTX_status = "status" + OTX_is_threat = "is_threat" + OTX_pulse_count = "pulse_count" + OTX_threats = "threat_labels" + + EX_WHOIS = "whois" + WHO_status = "status" + WHO_date = "creation_date" + WHO_age = "domain_age_days" + + # certification + Cert_valid = "valid" + Cert_issuer = "issuer" + Cert_validFrom = "validFrom" + Cert_validTo = "validTo" + Cert_status = "status_type" + Cert_error = "error" + + # meta + Meta_status = "status" + Meta_steps = "completed_steps" + Meta_warn = "warnings" \ No newline at end of file diff --git a/core/context.py b/core/context.py index 2f29e94..1a394bd 100644 --- a/core/context.py +++ b/core/context.py @@ -45,7 +45,7 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex "redirect_chain": [], "loop_detected": False, "status_type": [], #status code, 상태 상수 - "result": str # str로 success/fail 저장 + "result": None # str로 success/fail 저장 }, detection={ "shortened_url": { @@ -73,16 +73,24 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex "reason_summary": [], "matched_rules": [], }, - external={ + external={ # otx, whois 필드 추가 "safe_browsing": { "is_threat": False, "status_type": None, "result_type": None, "threat_labels": [] }, - "report_count": None, - "block_count": None, - "domain_age": None + "otx": { + "status": None, + "is_threat": False, + "pulse_count": 0, + "threat_labels": [] + }, + "whois": { + "status": None, + "creation_date": None, + "domain_age_days": 0 + } }, certificate = { "valid": False, diff --git a/core/pipeline.py b/core/pipeline.py index fbb5c2f..69534c2 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -1,28 +1,20 @@ # 선행 검사 결과에 따라 변동되는 검사 과정 세트를 지정합니다. -from datetime import datetime # [추가] - from analyzers import ( parameter_rule as rule, redirect_tracker as track, url_shorten_checker as short, ai_analyze as ai, req_api as api, - req_server as server + req_cert as cert, + #req_server as server +) +from services import ( + builder, + scoring, + url_utils as utils, + response as callback ) -from services import builder, scoring, url_utils as utils -from services import response as callback - -# [추가] 공통 progress 함수 -def _send_progress(context, step: str, status: str, message: str): - guest_Uuid = context.request.get("guest_Uuid") - callback.send_progress(guest_Uuid, step, status, message) - - -# [추가] 공통 최종 JSON 생성 -def _build_final_json(context) -> dict: - result = builder.build_result(context) - return builder.build_JSON(result) def run_pipeline(context): @@ -40,8 +32,7 @@ def run_pipeline(context): context.url["hostname"] = (parsed_result.get("hostname") or "").lower() _send_progress(context, "SHORT_URL_CHECK", "IN_PROGRESS", "단축 URL 여부를 확인하고 있습니다.") - short_result = short.url_shorten_checker(context) - context.detection["shortened_url"] = short_result + context.detection["shortened_url"] = short.url_shorten_checker(context) _send_progress(context, "SHORT_URL_CHECK", "COMPLETED", "단축 URL 여부 확인이 완료되었습니다.") if context.detection["shortened_url"].get("detected", False): @@ -55,14 +46,9 @@ def run_pipeline(context): _send_progress(context, "API_REQ", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") context.external = api.req_api(context) - context.certificate = server.req_server(context) + context.certificate = cert.req_cert(context) _send_progress(context, "API_REQ", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") - safe_browsing = context.external.get("safe_browsing", {}) - if safe_browsing.get("is_threat"): - context.scoring = scoring.score_analysis(context) - return _build_final_json(context) - _send_progress(context, "RULE_ANALYSIS", "IN_PROGRESS", "도메인 및 파라미터 규칙 분석을 진행하고 있습니다.") context.detection["rule_based"] = rule.parameter_rule(context) _send_progress(context, "RULE_ANALYSIS", "COMPLETED", "도메인 및 파라미터 규칙 분석이 완료되었습니다.") @@ -80,4 +66,16 @@ def run_pipeline(context): context.scoring = scoring.score_analysis(context) _send_progress(context, "SCORING", "COMPLETED", "최종 위험도 계산이 완료되었습니다.") - return _build_final_json(context) \ No newline at end of file + return _build_final_json(context) + + +# 중간 보고 전송용 wrapper +def _send_progress(context, step: str, status: str, message: str): + guest_Uuid = context.request.get("guest_Uuid") + callback.send_progress(guest_Uuid, step, status, message) + + +# 최종 JSON 생성 +def _build_final_json(context) -> dict: + result = builder.build_result(context) + return builder.build_JSON(result) \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/models/result.py b/models/result.py index f3ad017..e5d7bfd 100644 --- a/models/result.py +++ b/models/result.py @@ -34,8 +34,7 @@ class AnalysisResult: "redirectCount": 0 }) - threats: dict = field(default_factory=lambda: { - }) + threats: list[str] = field(default_factory=list) serverInfo: dict = field(default_factory=lambda: { "type": None, diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/builder.py b/services/builder.py index cf130aa..26dc650 100644 --- a/services/builder.py +++ b/services/builder.py @@ -1,54 +1,86 @@ # 검사 결과를 정리하고, 전송할 수 있는 형태로 build합니다. from datetime import datetime -from core.context import AnalysisContext + from models.result import AnalysisResult +from core.config import ctxData as ctx, statusData as stat, riskLevelData as lev + +from core.context import AnalysisContext + +# threats 작성 함수 def build_threats(context: AnalysisContext) -> list[str]: - labels = [] + threats = [] + + if context.detection[ctx.Det_short].get(ctx.Short_det): + threats.append("SHORTENED_URL") - if context.detection.get("shortened_url", {}).get("detected"): - labels.append("shortened_url") + for flag in context.detection[ctx.Det_rule].get(ctx.Rule_flags, []): + if flag not in threats: + threats.append(flag) - for flag in context.detection.get("rule_based", {}).get("flags", []): - if flag not in labels: - labels.append(flag) + for label in context.external[ctx.EX_GSB].get(ctx.GSB_threats, []): + if label not in threats: + threats.append(label) - for label in context.external.get("safe_browsing", {}).get("threat_labels", []): - if label not in labels: - labels.append(label) + for label in context.external[ctx.EX_OTX].get(ctx.OTX_threats, []): + if label not in threats: + threats.append(label) - for error in context.certificate.get("error", []): - if error not in labels: - labels.append(error) + for error in context.certificate.get(ctx.Cert_error, []): + if error not in threats: + threats.append(error) - return labels + return threats +# externalApi-result 작성 함수 def build_external_result(context: AnalysisContext) -> str | None: - safe_browsing = context.external.get("safe_browsing", {}) + safe_browsing = context.external.get(ctx.EX_GSB, {}) + otx = context.external.get(ctx.EX_OTX, {}) - status_type = safe_browsing.get("status_type") - result_type = safe_browsing.get("result_type") + if safe_browsing.get("is_threat") or otx.get("is_threat"): + return "THREAT" - status = status_type[1] if isinstance(status_type, (list, tuple)) and len(status_type) > 1 else status_type + gsb_status = safe_browsing.get("status_type") + otx_status = otx.get("status") - if status in ("timeout", "client_error", "server_error", "request_failed"): - return status_type - if result_type is not None: - return str(result_type) - if safe_browsing.get("is_threat"): - return "THREAT" - if status_type is not None: + gsb_success = ( + isinstance(gsb_status, list) + and len(gsb_status) >= 2 + and gsb_status[1] == "success" + ) + + otx_success = otx_status == "success" + + if gsb_success or otx_success: return "SAFE" - return None + return "UNKNOWN" + + +# externalApi-provider 작성 함수 +def build_external_provider(context) -> str: + providers = [] -def build_result(context: AnalysisContext) -> AnalysisResult: safe_browsing = context.external.get("safe_browsing", {}) + otx = context.external.get("otx", {}) + + if safe_browsing.get("status_type") is not None: + providers.append("Google Safe Browsing") + + if otx.get("status") is not None: + providers.append("AlienVault OTX") + + return ", ".join(providers) + + +def build_result(context: AnalysisContext) -> AnalysisResult: + safe_browsing = context.external.get(ctx.EX_GSB, {}) + otx = context.external.get(ctx.EX_OTX, {}) result = AnalysisResult( - guestUuid = context.request.get("guest_Uuid", {}), + guestUuid=context.request.get("guest_Uuid", ""), analysisTime=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"), originalUrl=context.request.get("original_url") or "" ) @@ -56,23 +88,24 @@ def build_result(context: AnalysisContext) -> AnalysisResult: result.https["isSecure"] = bool(context.url.get("is_https", False)) result.shortUrl["isShortened"] = bool( - context.detection.get("shortened_url", {}).get("detected", False) + context.detection.get(ctx.Det_short, {}).get(ctx.Short_det, False) ) result.threats = build_threats(context) result.ml = context.ml or {} result.externalApi["checked"] = ( - safe_browsing.get("status_type") is not None - or safe_browsing.get("result_type") is not None + safe_browsing.get(ctx.GSB_status) is not None + or safe_browsing.get(ctx.GSB_result) is not None + or otx.get(ctx.OTX_status) is not None ) - result.externalApi["provider"] = "Google Safe Browsing" + result.externalApi["provider"] = build_external_provider(context) result.externalApi["result"] = build_external_result(context) result.redirect["finalUrl"] = ( context.redirect.get("final_url") or context.url.get("normalized_url") - or context.url.get("original_url") + or context.request.get("original_url") ) result.redirect["redirectCount"] = int(context.redirect.get("redirect_count", 0) or 0) diff --git a/services/response.py b/services/response.py index 531c047..f334e65 100644 --- a/services/response.py +++ b/services/response.py @@ -10,6 +10,23 @@ TIMEOUT_SECONDS = 5 + +# 중간보고 +def send_progress(guest_Uuid: str, step: str, status: str, message: str): + payload = { + "guestUuid": guest_Uuid, + "step": step, + "status": status, + "message": message + } + return _post_json(CALLBACK_PROGRESS_URL, guest_Uuid, payload) + + +# 최종보고 +def send_final_result(guest_Uuid: str, result_json: dict): + return _post_json(CALLBACK_FINAL_URL, guest_Uuid, result_json) + + def _build_headers(guest_Uuid: str) -> dict: return { "X-ML-Secret": ML_SECRET_KEY or "", @@ -18,7 +35,7 @@ def _build_headers(guest_Uuid: str) -> dict: } -def _post_json(url: str | None, guest_Uuid: str, payload: dict) -> dict: +def _post_json(url: str | None, guest_Uuid: str, payload: dict): # URL 없으면 → 실패 JSON 생성 후 종료 if not url: payload.update({ @@ -69,20 +86,4 @@ def _post_json(url: str | None, guest_Uuid: str, payload: dict) -> dict: "message": str(e) }) print("post_json: ERROR") - return False - - -# [추가] 중간보고 -def send_progress(guest_Uuid: str, step: str, status: str, message: str) -> dict: - payload = { - "guestUuid": guest_Uuid, - "step": step, - "status": status, - "message": message - } - return _post_json(CALLBACK_PROGRESS_URL, guest_Uuid, payload) - - -# [추가] 최종보고 -def send_final_result(guest_Uuid: str, result_json: dict) -> dict: - return _post_json(CALLBACK_FINAL_URL, guest_Uuid, result_json) \ No newline at end of file + return False \ No newline at end of file diff --git a/services/scoring.py b/services/scoring.py index 46ce3dc..5755899 100644 --- a/services/scoring.py +++ b/services/scoring.py @@ -2,6 +2,8 @@ from core.context import AnalysisContext + +# 메인 점수 계산 함수 def score_analysis(context: AnalysisContext) -> dict: internal_score, internal_reasons = score_internal(context) ml_score = score_ml(context) @@ -13,13 +15,16 @@ def score_analysis(context: AnalysisContext) -> dict: external_score * 0.20 ) - # Safe Browsing 위험이면 강제 threat safe_browsing = context.external.get("safe_browsing", {}) + otx = context.external.get("otx", {}) + if safe_browsing.get("is_threat"): final_score = max(final_score, 85) - final_score = max(0, min(100, final_score)) - risk_level = decide_risk_level(final_score, safe_browsing) + if otx.get("is_threat"): + final_score = max(final_score, 65) + + risk_level = decide_risk_level(final_score, safe_browsing, otx) return build_scoring_result( internal_score=internal_score, @@ -32,6 +37,7 @@ def score_analysis(context: AnalysisContext) -> dict: ) +# 내부 로직 결과 점수 def score_internal(context: AnalysisContext) -> tuple[int, list]: score = 0 reasons = [] @@ -88,51 +94,62 @@ def score_internal(context: AnalysisContext) -> tuple[int, list]: def score_ml(context: AnalysisContext) -> int: score = context.ml.get("score") + if score is None: # None방어 추가 + return 0 + try: - score = int(round(score)) - except Exception: - score = 0 + score = int(round(float(score))) + except (TypeError, ValueError): + return 0 return max(0, min(100, score)) +# 외부 조회 결과 점수 def score_external(context: AnalysisContext) -> tuple[int, list]: score = 0 reasons = [] external = context.external - report_count = external.get("report_count") - if isinstance(report_count, int): - if 1 <= report_count <= 2: - score += 10 - reasons.append("report_count_low") - elif 3 <= report_count <= 5: - score += 20 - reasons.append("report_count_mid") - elif report_count >= 6: - score += 30 - reasons.append("report_count_high") - - block_count = external.get("block_count") - if isinstance(block_count, int): - if 1 <= block_count <= 2: + otx = external.get("otx", {}) + pulse_count = otx.get("pulse_count", 0) + + # otx 반영 + if otx.get("is_threat"): + score += 25 + reasons.append("otx_threat_detected") + + if isinstance(pulse_count, int): + if 1 <= pulse_count <= 2: + score += 5 + reasons.append("otx_count_low") + elif 3 <= pulse_count <= 5: score += 10 - reasons.append("block_count_low") - elif block_count >= 3: - score += 20 - reasons.append("block_count_high") + reasons.append("otx_count_mid") + elif pulse_count >= 6: + score += 15 + reasons.append("otx_count_high") + # 인증서 유효 여부 cert_valid = context.certificate.get("valid") if cert_valid is False: score += 25 reasons.append("invalid_certificate") - domain_age = external.get("domain_age") - if domain_age in ("new", "very_new"): - score += 15 - reasons.append("young_domain") + # whois 반영 + whois = external.get("whois", {}) + domain_age_days = whois.get("domain_age_days") + if isinstance(domain_age_days, int) and domain_age_days > 0: + if domain_age_days <= 30: + score += 15 + reasons.append("very_young_domain") + elif domain_age_days <= 180: + score += 8 + reasons.append("young_domain") + + # 서버 유형 server_type = external.get("server_type") if server_type in (None, "", "unknown"): score += 10 @@ -141,15 +158,19 @@ def score_external(context: AnalysisContext) -> tuple[int, list]: return min(score, 100), reasons -def decide_risk_level(final_score: int, safe_browsing: dict) -> str: +# 위험도 반환 +def decide_risk_level(final_score: int, safe_browsing: dict, otx: dict) -> str: if safe_browsing.get("is_threat"): - return "threat" + return "THREAT" + + if otx.get("is_threat") and final_score >= 60: + return "THREAT" if final_score >= 60: - return "threat" + return "THREAT" if final_score >= 30: - return "caution" - return "safe" + return "CAUTION" + return "SAFE" def collect_matched_rules(context: AnalysisContext) -> list: @@ -165,6 +186,9 @@ def collect_matched_rules(context: AnalysisContext) -> list: safe_browsing = context.external.get("safe_browsing", {}) matched.extend(safe_browsing.get("threat_labels", [])) + otx = context.external.get("otx", {}) + matched.extend(otx.get("threat_labels", [])) + ml_result = context.ml or {} matched.extend(ml_result.get("threats", [])) diff --git a/services/url_utils.py b/services/url_utils.py index af85b62..c2d49d5 100644 --- a/services/url_utils.py +++ b/services/url_utils.py @@ -4,7 +4,7 @@ #입력 URL의 앞뒤 공백/탭/개행 제거 -def sanitize_input_url(raw_url: str) -> str: +def sanitize_input_url(raw_url: str | None) -> str: if raw_url is None: raise ValueError("URL 입력값이 None입니다.") @@ -59,7 +59,9 @@ def normalize_scheme(parsed_result:dict): } -#hostname 소문자화, 기본 포트 여부 판단 +# hostname 소문자화, 기본 포트 여부 판단 +# 수정 : 기본 포트일 시 기본포트 제거 부분 삭제. +# 사유 : feature 추출, server/인증서 조회 사용에 수월함 def normalize_url_components(parsed_result: dict, normalized_scheme: str): hostname = parsed_result["hostname"] # hostname port = parsed_result["port"] # port @@ -73,14 +75,13 @@ def normalize_url_components(parsed_result: dict, normalized_scheme: str): # 기본 포트 여부 확인(http/https의 경우만) if normalized_scheme == "http" and port == 80: is_default_port = True - port = None + elif normalized_scheme == "https" and port == 443: is_default_port = True - port = None return { "normalized_hostname": hostname, - "normalized_port": port, # 기본 포트 제거 반영 + "port": port, "is_default_port": is_default_port, # 기본 포트 여부 } #normalized_comp @@ -88,10 +89,10 @@ def normalize_url_components(parsed_result: dict, normalized_scheme: str): #URL 재구성 def rebuild_normalized_url(parsed_result: dict, normalized_scheme: str, normalized_comp: dict): # hostname+port - if normalized_comp["normalized_port"] is None: + if normalized_comp["port"] is None: netloc = normalized_comp["normalized_hostname"] else: - netloc = f"{normalized_comp['normalized_hostname']}:{normalized_comp['normalized_port']}" + netloc = f"{normalized_comp['normalized_hostname']}:{normalized_comp['port']}" # normalized url (output) integrated_url = urlunsplit(( @@ -132,7 +133,7 @@ def normalize_url(context: AnalysisContext) -> None: context.url["normalized_url"] = rebuilt_result["normalized_url"] context.url["scheme"] = scheme_result["normalized_scheme"] context.url["hostname"] = normalized_comp["normalized_hostname"] - context.url["port"] = normalized_comp["normalized_port"] + context.url["port"] = normalized_comp["port"] context.url["path"] = parsed_result["path"] context.url["query"] = parsed_result["query"] context.url["fragment"] = parsed_result["fragment"] diff --git a/analyzers/api_test.py b/test/api_test.py similarity index 100% rename from analyzers/api_test.py rename to test/api_test.py diff --git a/test_api.py b/test/test_api.py similarity index 100% rename from test_api.py rename to test/test_api.py From 49e9132fdda11690ddd69f766a9d3acb76080901 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sun, 3 May 2026 08:43:00 +0900 Subject: [PATCH 64/77] Update README.md --- README.md | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ed693aa..f0fb1be 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,11 @@ # analysis-engine -be2 -python- FastAPI -api: google safe browsing -*** +## 다음 수정 : +이것도 금일(0503) 내로 진행합니다...... -## 현재 단계 : API, DB 조회 오류 수정, URL정규화+단축/https검사 구현 -#### 지난 단계: API, DB 조회 구현 +#### 1. server info조회 결과와 context 간 연결, 결과 json에 확실히 반영되도록 안정화 -조회 부분까지의 업로드를 다른 브랜치에 하려 했으나 실수로 main에 집어넣었습니다. -오류 수정을 포함한 이후 단계부터는 develop, 또는 develop/기능 이름 브랜치에서 진행합니다. +#### 2. api조회 비동기 반영 수정 -*** - -### API, DB 조회 상세 설명 -+ 테스트를 위한 프로토타입 목적입니다. (최종 형태 X) - + 작동 구조만 맞추는 것에 가깝습니다. - + 더미 데이터로 테스트하며, 응답을 저장하지 않습니다. -+ API는 google safe browsing만을 조회합니다. -+ DB 조회 형식이 확정되지 않았습니다. - + case1> 요청-응답 : 분석 서버에서 메인 서버로 조회 요청 전송, 조회 결과를 응답받습니다. <현재 - + case2> 직접 조회(pymysql) : 분석 서버에서 MySQL DB로 직접 질의를 통해 조회합니다. +#### 3. feature 추출기 결합 + 학습한 모델 업로드 +*제발할수있었으면좋겠네요진짜* From f3ab2b58f2202a23c66b7ec9d9c54085b7d627f8 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sun, 3 May 2026 15:36:25 +0900 Subject: [PATCH 65/77] =?UTF-8?q?*reportCount,=20domainAge=20builder?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/external/req_whois.py | 17 +++++++++++++++-- models/result.py | 2 ++ services/builder.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/analyzers/external/req_whois.py b/analyzers/external/req_whois.py index a0d88b9..549c803 100644 --- a/analyzers/external/req_whois.py +++ b/analyzers/external/req_whois.py @@ -1,6 +1,6 @@ import whois import tldextract -from datetime import datetime +from datetime import datetime, date from core.config import statusData as stat @@ -50,10 +50,23 @@ def parse_WHOIS_result(raw: dict) -> str | None: creation_date = data.get("creation_date") if isinstance(data, dict) else getattr(data, "creation_date", None) if isinstance(creation_date, list): - creation_date = creation_date[0] if creation_date else None + creation_date = next((item for item in creation_date if item), None) if isinstance(creation_date, datetime): return creation_date.strftime("%Y-%m-%d") + + # creation_date가 문자열일 시 처리 추가 + if isinstance(creation_date, date): + return creation_date.isoformat() + + if isinstance(creation_date, str): + creation_date = creation_date.strip() + + for fmt in ("%Y-%m-%d", "%Y.%m.%d", "%Y/%m/%d"): + try: + return datetime.strptime(creation_date[:10], fmt).strftime("%Y-%m-%d") + except ValueError: + pass return None diff --git a/models/result.py b/models/result.py index e5d7bfd..4e7684d 100644 --- a/models/result.py +++ b/models/result.py @@ -28,6 +28,8 @@ class AnalysisResult: "provider": None, "result": None }) + reportCount: int | None = None + domainAge: int | None = None redirect: dict = field(default_factory=lambda: { "finalUrl": None, diff --git a/services/builder.py b/services/builder.py index 26dc650..b6a8eda 100644 --- a/services/builder.py +++ b/services/builder.py @@ -75,6 +75,33 @@ def build_external_provider(context) -> str: return ", ".join(providers) +# report count, domainage 추가 +def build_report_count(context: AnalysisContext) -> int | None: + otx = context.external.get("otx", {}) + pulse_count = otx.get("pulse_count") + + if isinstance(pulse_count, int): + return pulse_count + + try: + return int(pulse_count) + except (TypeError, ValueError): + return None + + +def build_domain_age(context: AnalysisContext) -> int | None: + whois = context.external.get("whois", {}) + domain_age_days = whois.get("domain_age_days") + + if isinstance(domain_age_days, int): + return domain_age_days + + try: + return int(domain_age_days) + except (TypeError, ValueError): + return None + + def build_result(context: AnalysisContext) -> AnalysisResult: safe_browsing = context.external.get(ctx.EX_GSB, {}) otx = context.external.get(ctx.EX_OTX, {}) @@ -91,6 +118,8 @@ def build_result(context: AnalysisContext) -> AnalysisResult: context.detection.get(ctx.Det_short, {}).get(ctx.Short_det, False) ) + result.reportCount = build_report_count(context) + result.domainAge = build_domain_age(context) result.threats = build_threats(context) result.ml = context.ml or {} @@ -132,6 +161,8 @@ def build_JSON(result: AnalysisResult) -> dict: "threats": result.threats, "ml": result.ml, "externalApi": result.externalApi, + "reportCount": result.reportCount, + "domainAge": result.domainAge, "redirect": result.redirect, "serverInfo": result.serverInfo, "score": result.score, From 7638ad10f63cf9f241797a0f0696cee2d0925097 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Sun, 3 May 2026 17:04:01 +0900 Subject: [PATCH 66/77] =?UTF-8?q?*domainAge=20null=EA=B0=92=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/builder.py | 58 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/services/builder.py b/services/builder.py index b6a8eda..49b6e9e 100644 --- a/services/builder.py +++ b/services/builder.py @@ -35,29 +35,73 @@ def build_threats(context: AnalysisContext) -> list[str]: # externalApi-result 작성 함수 -def build_external_result(context: AnalysisContext) -> str | None: +def build_external_result(context: AnalysisContext) -> str: safe_browsing = context.external.get(ctx.EX_GSB, {}) otx = context.external.get(ctx.EX_OTX, {}) + whois = context.external.get(ctx.EX_WHOIS, {}) + # 1. 위협 탐지 우선 if safe_browsing.get("is_threat") or otx.get("is_threat"): return "THREAT" + # 2. 상태값 확인 gsb_status = safe_browsing.get("status_type") otx_status = otx.get("status") + whois_status = whois.get("status") - gsb_success = ( - isinstance(gsb_status, list) - and len(gsb_status) >= 2 - and gsb_status[1] == "success" - ) + gsb_success = _is_success(gsb_status) + otx_success = _is_success(otx_status) + whois_success = _is_success(whois_status) + + failed = [] + + if safe_browsing and not gsb_success: + failed.append("GSB") + + if otx and not otx_success: + failed.append("OTX") - otx_success = otx_status == "success" + if whois and not whois_success: + failed.append("WHOIS") + # 3. 실패 API 명시 + if failed: + return "_".join(failed) + "_FAILED" + + # 4. 하나라도 성공했고 위협이 없으면 SAFE if gsb_success or otx_success: return "SAFE" + # 5. 아무것도 판단 못 함 return "UNKNOWN" +# externalApi 성공/실패 판정용 함수 +def _status_value(status): + if status is None: + return None + + if isinstance(status, (list, tuple)): + if len(status) >= 2: + return str(status[1]).lower() + if len(status) == 1: + return str(status[0]).lower() + + return str(status).lower() + + +def _is_success(status) -> bool: + value = _status_value(status) + return value in ("success", "ok", "200") + + +def _is_failed(status) -> bool: + value = _status_value(status) + + if value is None: + return False + + return value not in ("success", "ok", "200") + # externalApi-provider 작성 함수 def build_external_provider(context) -> str: From 24032e64ed5840ca0852ef950ee8a8b7e2424085 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Mon, 4 May 2026 00:19:01 +0900 Subject: [PATCH 67/77] =?UTF-8?q?*serverInfo=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/req_server/__init__.py | 5 --- analyzers/req_server/constants.py | 4 -- analyzers/req_server/req_server.py | 49 +++++++++++++++++------- analyzers/req_server/server_info.py | 25 +++++------- analyzers/req_server/server_url_utils.py | 42 ++++++++------------ core/config.py | 4 ++ core/context.py | 5 ++- core/pipeline.py | 9 ++++- services/builder.py | 18 +++++---- services/url_utils.py | 5 ++- 10 files changed, 92 insertions(+), 74 deletions(-) diff --git a/analyzers/req_server/__init__.py b/analyzers/req_server/__init__.py index 14ae7c0..d8673b8 100644 --- a/analyzers/req_server/__init__.py +++ b/analyzers/req_server/__init__.py @@ -1,10 +1,5 @@ -from .certificate import build_server_result, cert_req_api, parse_cert_result, parse_issuer from .req_server import req_server __all__ = [ - "build_server_result", - "cert_req_api", - "parse_cert_result", - "parse_issuer", "req_server", ] diff --git a/analyzers/req_server/constants.py b/analyzers/req_server/constants.py index e026dd3..1987add 100644 --- a/analyzers/req_server/constants.py +++ b/analyzers/req_server/constants.py @@ -1,13 +1,9 @@ STATUS_SUCCESS = "success" STATUS_TIMEOUT = "timeout" -STATUS_SSL_ERROR = "ssl_error" -STATUS_VERIFY_FAILED = "verify_failed" STATUS_REQUEST_FAILED = "request_failed" STATUS_INVALID_INPUT = "invalid_input" STATUS_UNEXPECTED_ERROR = "unexpected_error" -STATUS_NO_CERTIFICATE = "no_certificate" -CERT_TIMEOUT = 5 SERVER_INFO_TIMEOUT = 3 UNKNOWN_SERVER_INFO = "unknown" UNKNOWN_LOCATION_INFO = "위치 정보 없음" diff --git a/analyzers/req_server/req_server.py b/analyzers/req_server/req_server.py index c180253..cff7227 100644 --- a/analyzers/req_server/req_server.py +++ b/analyzers/req_server/req_server.py @@ -1,25 +1,48 @@ from core.context import AnalysisContext +from core.config import ctxData as ctx -from .constants import STATUS_UNEXPECTED_ERROR -from .server_info import update_context_server_info -from .server_url_utils import parse_target_host +from .constants import UNKNOWN_SERVER_INFO, UNKNOWN_LOCATION_INFO +from .server_info import fetch_server_type, fetch_server_location +from .server_url_utils import parse_target_host, hostname_idna +# server 정보 조회 메인 함수 def req_server(context: AnalysisContext) -> dict: - hostname = context.url.get("hostname") - port = context.url.get("port") + hostname = context.url.get(ctx.Url_hostname) + hostname = hostname_idna(hostname) + port = context.url.get(ctx.Url_port) + scheme = context.url.get(ctx.Url_scheme) + + norm_url = context.url.get(ctx.Url_normalized_url) + origin_url = context.request.get(ctx.Req_original_url) + urls = [norm_url, origin_url] if not hostname: - hostname, fallback_port = parse_target_host(context) + hostname, fallback_port = parse_target_host(urls) port = port or fallback_port port = port or 443 - update_context_server_info(context, hostname, port) - try: - raw = cert_req_api(hostname, port) - parsed = parse_cert_result(raw) - except Exception as e: - parsed = build_cert_failure(STATUS_UNEXPECTED_ERROR, e) + server_type = fetch_server_type(urls, scheme, hostname, port) + server_location = fetch_server_location(hostname) + server_status = build_server_status(server_type, server_location) - return build_server_result(parsed) + return { + "server_type": server_type, + "server_location": server_location, + "server_status": server_status, + } + + + +def build_server_status(server_type: str, server_location: str) -> str: + type_ok = server_type != UNKNOWN_SERVER_INFO + location_ok = server_location != UNKNOWN_LOCATION_INFO + + if type_ok and location_ok: + return "success" + + if type_ok or location_ok: + return "partial" + + return "failed" diff --git a/analyzers/req_server/server_info.py b/analyzers/req_server/server_info.py index 623b3e0..631a880 100644 --- a/analyzers/req_server/server_info.py +++ b/analyzers/req_server/server_info.py @@ -1,5 +1,5 @@ import socket - +import urllib3 import requests from .constants import ( @@ -9,28 +9,22 @@ USER_AGENT, ) from .location import format_location_ko -from .server_url_utils import build_probe_urls, normalize_hostname +from .server_url_utils import build_probe_urls -try: - requests.packages.urllib3.disable_warnings() -except AttributeError: - pass +urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -def update_context_server_info(context, hostname: str | None, port: int | str | None) -> None: - if not isinstance(getattr(context, "external", None), dict): - context.external = {} - context.external["server_type"] = fetch_server_type(context, hostname, port) - context.external["server_location"] = fetch_server_location(hostname) +# 서버 타입을 조회합니다. context 수정x +# 반환: server_type 문자열 +def fetch_server_type(urls:list, scheme, hostname: str | None, port: int | str | None) -> str: + # context에 저장되어 있는 정규화된 hostname를 받아서 사용합니다. -def fetch_server_type(context, hostname: str | None, port: int | str | None) -> str: - hostname = normalize_hostname(hostname) if not hostname: return UNKNOWN_SERVER_INFO - for url in build_probe_urls(context, hostname, port): + for url in build_probe_urls(urls, scheme, hostname, port): try: response = requests.head( url, @@ -51,8 +45,9 @@ def fetch_server_type(context, hostname: str | None, port: int | str | None) -> return UNKNOWN_SERVER_INFO +# 서버 위치를 조회합니다. +# 반환: server_location 문자열 def fetch_server_location(hostname: str | None) -> str: - hostname = normalize_hostname(hostname) if not hostname: return UNKNOWN_LOCATION_INFO diff --git a/analyzers/req_server/server_url_utils.py b/analyzers/req_server/server_url_utils.py index a96fb92..e8e7036 100644 --- a/analyzers/req_server/server_url_utils.py +++ b/analyzers/req_server/server_url_utils.py @@ -1,19 +1,12 @@ from urllib.parse import urlsplit -def parse_target_host(context) -> tuple[str | None, int | None]: - norm_url = context.url.get("normalized_url") - origin_url = context.request.get("original_url") - candidate_urls = [ - norm_url, - origin_url - ] - - for candidate_url in candidate_urls: +def parse_target_host(urls: list) -> tuple[str | None, int | None]: + for candidate_url in urls or []: hostname, port = parse_host_from_url(candidate_url) if hostname: - return hostname, port + return hostname_idna(hostname), port return None, None @@ -38,30 +31,26 @@ def parse_host_from_url(url: str | None) -> tuple[str | None, int | None]: return split_result.hostname, port -def build_probe_urls(context, hostname: str, port: int | str | None) -> list[str]: - urls = [] - norm_url = context.url.get("normalized_url") - origin_url = context.request.get("original_url") +# hostname기준 HEAD 요청을 던질 URL 리스트 제작 함수 +def build_probe_urls(urls: list, scheme: str | None, hostname: str, port: int | str | None) -> list[str]: + result = [] - for candidate_url in [ - norm_url, - origin_url - ]: + for candidate_url in urls or []: url = normalize_probe_url(candidate_url) candidate_hostname, _ = parse_host_from_url(url) - if normalize_hostname(candidate_hostname) == hostname: - urls.append(url) + if hostname_idna(candidate_hostname) == hostname: + result.append(url) netloc = build_netloc(hostname, port) - scheme = context.url.get("scheme") + if scheme in ("http", "https"): - urls.append(f"{scheme}://{netloc}") + result.append(f"{scheme}://{netloc}") - urls.append(f"https://{netloc}") - urls.append(f"http://{netloc}") + result.append(f"https://{netloc}") + result.append(f"http://{netloc}") - return unique_values(urls) + return unique_values(result) def normalize_probe_url(url: str | None) -> str | None: @@ -85,7 +74,8 @@ def build_netloc(hostname: str, port: int | str | None) -> str: return hostname -def normalize_hostname(hostname: str | None) -> str | None: +# 기능 혼동 방지 위한 명칭 변경 +def hostname_idna(hostname: str | None) -> str | None: if not isinstance(hostname, str): return None diff --git a/core/config.py b/core/config.py index 5f53f99..adec3ca 100644 --- a/core/config.py +++ b/core/config.py @@ -71,6 +71,10 @@ class ctxData: Sco_matched = "matched_rules" # external + Ex_server_type = "server_type" + Ex_server_loc = "server_location" + Ex_server_status = "server_status" + EX_GSB = "safe_browsing" GSB_is_threat = "is_threat" GSB_status = "status_type" diff --git a/core/context.py b/core/context.py index 1a394bd..14c2ec0 100644 --- a/core/context.py +++ b/core/context.py @@ -73,7 +73,10 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex "reason_summary": [], "matched_rules": [], }, - external={ # otx, whois 필드 추가 + external={ # otx, whois 필드 추가, serverinfo 필드 추가 + "server_type": None, + "server_location": None, + "server_status": None, "safe_browsing": { "is_threat": False, "status_type": None, diff --git a/core/pipeline.py b/core/pipeline.py index 69534c2..b2c2966 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -6,8 +6,8 @@ ai_analyze as ai, req_api as api, req_cert as cert, - #req_server as server ) +from analyzers.req_server import req_server from services import ( builder, scoring, @@ -45,7 +45,12 @@ def run_pipeline(context): _send_progress(context, "URL_NORMALIZE", "COMPLETED", "URL 정규화가 완료되었습니다.") _send_progress(context, "API_REQ", "IN_PROGRESS", "외부 위협 정보를 조회하고 있습니다.") - context.external = api.req_api(context) + server_result = req_server(context) + context.external.update(server_result) + + api_result = api.req_api(context) + context.external.update(api_result) + context.certificate = cert.req_cert(context) _send_progress(context, "API_REQ", "COMPLETED", "외부 위협 정보 조회가 완료되었습니다.") diff --git a/services/builder.py b/services/builder.py index b6a8eda..db3eba0 100644 --- a/services/builder.py +++ b/services/builder.py @@ -12,22 +12,26 @@ def build_threats(context: AnalysisContext) -> list[str]: threats = [] - if context.detection[ctx.Det_short].get(ctx.Short_det): + if context.detection.get(ctx.Det_short, {}).get(ctx.Short_det): threats.append("SHORTENED_URL") - for flag in context.detection[ctx.Det_rule].get(ctx.Rule_flags, []): + for flag in context.detection.get(ctx.Det_rule, {}).get(ctx.Rule_flags, []): if flag not in threats: threats.append(flag) - for label in context.external[ctx.EX_GSB].get(ctx.GSB_threats, []): + for label in context.external.get(ctx.EX_GSB, {}).get(ctx.GSB_threats, []): if label not in threats: threats.append(label) - for label in context.external[ctx.EX_OTX].get(ctx.OTX_threats, []): + for label in context.external.get(ctx.EX_OTX, {}).get(ctx.OTX_threats, []): if label not in threats: threats.append(label) - for error in context.certificate.get(ctx.Cert_error, []): + cert_errors = context.certificate.get(ctx.Cert_error, []) + if isinstance(cert_errors, str): + cert_errors = [cert_errors] + + for error in cert_errors: if error not in threats: threats.append(error) @@ -60,7 +64,7 @@ def build_external_result(context: AnalysisContext) -> str | None: # externalApi-provider 작성 함수 -def build_external_provider(context) -> str: +def build_external_provider(context) -> str | None: providers = [] safe_browsing = context.external.get("safe_browsing", {}) @@ -72,7 +76,7 @@ def build_external_provider(context) -> str: if otx.get("status") is not None: providers.append("AlienVault OTX") - return ", ".join(providers) + return ", ".join(providers) if providers else None # report count, domainage 추가 diff --git a/services/url_utils.py b/services/url_utils.py index c2d49d5..750ffe7 100644 --- a/services/url_utils.py +++ b/services/url_utils.py @@ -112,7 +112,10 @@ def rebuild_normalized_url(parsed_result: dict, normalized_scheme: str, normaliz def normalize_url(context: AnalysisContext) -> None: # 단축 URL이면 리다이렉트 후 최종 URL을 사용, 아니면 원본 URL 사용 if context.detection["shortened_url"]["detected"]: - target_url = context.redirect.get("final_url") + target_url = ( + context.redirect.get("final_url") + or context.request.get("original_url") + ) else: target_url = context.request.get("original_url") From 2664ffe5cfc14bf550b8f69b4ee5cf5736290052 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 8 May 2026 09:46:04 +0900 Subject: [PATCH 68/77] =?UTF-8?q?*CharCNN=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 ++ analyzers/ai_analyze.py | 57 +++++++------- core/pipeline.py | 13 +++- ml/__init__.py | 1 + ml/charcnn/__init__.py | 1 + ml/charcnn/config.py | 16 ++++ ml/charcnn/model.py | 44 +++++++++++ ml/charcnn/predictor.py | 128 +++++++++++++++++++++++++++++++ ml/charcnn/saved/README.md | 11 +++ ml/charcnn/saved/char_vocab.json | 63 +++++++++++++++ ml/charcnn/saved/charcnn.pt | Bin 0 -> 414972 bytes 11 files changed, 311 insertions(+), 31 deletions(-) create mode 100644 ml/__init__.py create mode 100644 ml/charcnn/__init__.py create mode 100644 ml/charcnn/config.py create mode 100644 ml/charcnn/model.py create mode 100644 ml/charcnn/predictor.py create mode 100644 ml/charcnn/saved/README.md create mode 100644 ml/charcnn/saved/char_vocab.json create mode 100644 ml/charcnn/saved/charcnn.pt diff --git a/.gitignore b/.gitignore index 961db9d..1c39702 100644 --- a/.gitignore +++ b/.gitignore @@ -206,3 +206,11 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# 검사기 테스트 결과 및 테스트 도구 +test +test/ +test-data +test-data/ +eval_results +tools diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py index b1897fb..312fcb2 100644 --- a/analyzers/ai_analyze.py +++ b/analyzers/ai_analyze.py @@ -1,50 +1,53 @@ -# pre-trained된 ML모델을 호출해 분석을 진행합니다. -# ML 기반 검사 로직입니다. -# 현재는 학습된 모델 대신 더미 추론 결과를 반환합니다. +# pre-trained ML 모델을 호출해 분석을 진행합니다. +# 현재 연결 대상: CharCNN 추론 전용 모듈 from core.context import AnalysisContext +from ml.charcnn.predictor import predict_url -# [수정] ML 입력 URL 결정 함수 추가 +# ML 입력 URL 결정 함수 +# 요구사항: redirect 추적 결과가 있으면 final_url을 우선 사용한다. def resolve_ml_target_url(context: AnalysisContext) -> str | None: - if context.detection["shortened_url"]["detected"] == True: - return context.redirect.get("final_url") or context.url.get("normalized_url") + final_url = context.redirect.get("final_url") + if final_url: + return final_url - if context.redirect.get("was_checked") and context.redirect.get("final_url"): - return context.redirect.get("final_url") + normalized_url = context.url.get("normalized_url") + if normalized_url: + return normalized_url - return context.url.get("normalized_url") or context.request.get("original_url") + return context.request.get("original_url") def ai_analyze(context: AnalysisContext) -> dict: - # [수정] final_url 고정 사용 제거 target_url = resolve_ml_target_url(context) - - result = run_ml_inference(target_url) + charcnn_result = run_ml_inference(target_url) return build_ml_result( - score=result["score"], - threats=result["threats"] + score=charcnn_result.get("score", 0), + threats=charcnn_result.get("threats", []), + charcnn=charcnn_result, ) def run_ml_inference(target_url: str | None) -> dict: - if not target_url: - return { - "score": 0, - "threats": [] - } + return predict_url(target_url) - return { - "score": 61, - "threats": ["dummy_ml_threat"] - } - -def build_ml_result(score: int = 0, threats: list | None = None) -> dict: +def build_ml_result( + score: int = 0, + threats: list | None = None, + charcnn: dict | None = None, +) -> dict: if threats is None: threats = [] return { "score": score, - "threats": threats - } \ No newline at end of file + "threats": threats, + "charcnn": charcnn or { + "available": False, + "score": 0, + "label": "unknown", + "threats": [], + }, + } diff --git a/core/pipeline.py b/core/pipeline.py index b2c2966..2f788ae 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -58,15 +58,17 @@ def run_pipeline(context): context.detection["rule_based"] = rule.parameter_rule(context) _send_progress(context, "RULE_ANALYSIS", "COMPLETED", "도메인 및 파라미터 규칙 분석이 완료되었습니다.") - _send_progress(context, "ML_ANALYSIS", "IN_PROGRESS", "ML 기반 위험 분석을 진행하고 있습니다.") - context.ml = ai.ai_analyze(context) - _send_progress(context, "ML_ANALYSIS", "COMPLETED", "ML 기반 위험 분석이 완료되었습니다.") - + # CharCNN은 redirect를 거친 최종 URL을 입력으로 써야 하므로, + # 일반 URL의 redirect 추적을 ML 직전으로 당긴다. if not context.detection.get("shortened_url", {}).get("detected", False): _send_progress(context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 경로를 추적하고 있습니다.") context.redirect = track.redirect_tracker(context) _send_progress(context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 추적이 완료되었습니다.") + _send_progress(context, "ML_ANALYSIS", "IN_PROGRESS", "ML 기반 위험 분석을 진행하고 있습니다.") + context.ml = ai.ai_analyze(context) + _send_progress(context, "ML_ANALYSIS", "COMPLETED", "ML 기반 위험 분석이 완료되었습니다.") + _send_progress(context, "SCORING", "IN_PROGRESS", "최종 위험도를 계산하고 있습니다.") context.scoring = scoring.score_analysis(context) _send_progress(context, "SCORING", "COMPLETED", "최종 위험도 계산이 완료되었습니다.") @@ -76,6 +78,9 @@ def run_pipeline(context): # 중간 보고 전송용 wrapper def _send_progress(context, step: str, status: str, message: str): + if context.meta.get("disable_callback") is True: + return + guest_Uuid = context.request.get("guest_Uuid") callback.send_progress(guest_Uuid, step, status, message) diff --git a/ml/__init__.py b/ml/__init__.py new file mode 100644 index 0000000..e166116 --- /dev/null +++ b/ml/__init__.py @@ -0,0 +1 @@ +"""ML inference modules for the analysis engine.""" diff --git a/ml/charcnn/__init__.py b/ml/charcnn/__init__.py new file mode 100644 index 0000000..f042a03 --- /dev/null +++ b/ml/charcnn/__init__.py @@ -0,0 +1 @@ +"""CharCNN inference package.""" diff --git a/ml/charcnn/config.py b/ml/charcnn/config.py new file mode 100644 index 0000000..207872e --- /dev/null +++ b/ml/charcnn/config.py @@ -0,0 +1,16 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +SAVED_DIR = BASE_DIR / "saved" +MODEL_PATH = SAVED_DIR / "charcnn.pt" +VOCAB_PATH = SAVED_DIR / "char_vocab.json" + +MAX_LEN = 200 +EMBED_DIM = 64 +NUM_FILTERS = 128 +KERNEL_SIZES = [3, 4, 5] +DROPOUT = 0.3 +THRESHOLD = 0.5 + +PAD_TOKEN = "" +UNK_TOKEN = "" diff --git a/ml/charcnn/model.py b/ml/charcnn/model.py new file mode 100644 index 0000000..b5319cb --- /dev/null +++ b/ml/charcnn/model.py @@ -0,0 +1,44 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + +from .config import DROPOUT, EMBED_DIM, KERNEL_SIZES, NUM_FILTERS + + +class CharCNN(nn.Module): + """Character-level CNN for binary URL risk prediction. + + The saved artifact is expected to be a state_dict trained with the same + architecture values in ml/charcnn/config.py. + """ + + def __init__( + self, + vocab_size: int, + embed_dim: int = EMBED_DIM, + num_filters: int = NUM_FILTERS, + kernel_sizes: list[int] | tuple[int, ...] = KERNEL_SIZES, + dropout: float = DROPOUT, + ): + super().__init__() + self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0) + self.convs = nn.ModuleList( + [nn.Conv1d(embed_dim, num_filters, kernel_size=k) for k in kernel_sizes] + ) + self.dropout = nn.Dropout(dropout) + self.fc = nn.Linear(num_filters * len(kernel_sizes), 1) + + def forward(self, x): + # x: [batch, seq_len] + x = self.embedding(x) # [batch, seq_len, embed_dim] + x = x.permute(0, 2, 1) # [batch, embed_dim, seq_len] + + pooled = [] + for conv in self.convs: + h = F.relu(conv(x)) # [batch, num_filters, conv_len] + h = F.max_pool1d(h, h.size(2)).squeeze(2) + pooled.append(h) + + x = torch.cat(pooled, dim=1) + x = self.dropout(x) + return self.fc(x).squeeze(1) diff --git a/ml/charcnn/predictor.py b/ml/charcnn/predictor.py new file mode 100644 index 0000000..bc4dc2e --- /dev/null +++ b/ml/charcnn/predictor.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from .config import ( + MAX_LEN, + MODEL_PATH, + PAD_TOKEN, + THRESHOLD, + UNK_TOKEN, + VOCAB_PATH, +) + +LABEL_MALICIOUS = "malicious" +LABEL_NORMAL = "normal" +LABEL_UNKNOWN = "unknown" +THREAT_LABEL = "CHARCNN:SUSPICIOUS_URL_PATTERN" + + +def _unavailable_result(url: str | None, error: str) -> dict[str, Any]: + return { + "available": False, + "target_url": url, + "score": 0, + "probability": None, + "label": LABEL_UNKNOWN, + "threshold": THRESHOLD, + "threats": [], + "error": error, + } + + +def load_vocab(vocab_path: Path = VOCAB_PATH) -> dict[str, int]: + with open(vocab_path, "r", encoding="utf-8") as f: + vocab = json.load(f) + + if PAD_TOKEN not in vocab or UNK_TOKEN not in vocab: + raise ValueError(f"vocab must contain {PAD_TOKEN!r} and {UNK_TOKEN!r}") + + return vocab + + +def encode_url(url: str, vocab: dict[str, int], max_len: int = MAX_LEN) -> list[int]: + url = str(url) + unk_idx = vocab[UNK_TOKEN] + pad_idx = vocab[PAD_TOKEN] + + encoded = [vocab.get(ch, unk_idx) for ch in url] + + if len(encoded) < max_len: + encoded += [pad_idx] * (max_len - len(encoded)) + else: + encoded = encoded[:max_len] + + return encoded + + +class CharCNNPredictor: + def __init__( + self, + model_path: Path = MODEL_PATH, + vocab_path: Path = VOCAB_PATH, + threshold: float = THRESHOLD, + ): + self.model_path = Path(model_path) + self.vocab_path = Path(vocab_path) + self.threshold = threshold + + if not self.model_path.exists(): + raise FileNotFoundError(f"CharCNN model file not found: {self.model_path}") + if not self.vocab_path.exists(): + raise FileNotFoundError(f"CharCNN vocab file not found: {self.vocab_path}") + + try: + import torch + except ImportError as exc: + raise ImportError("torch is required for CharCNN inference") from exc + + from .model import CharCNN + + self.torch = torch + self.device = "cuda" if torch.cuda.is_available() else "cpu" + self.vocab = load_vocab(self.vocab_path) + self.model = CharCNN(vocab_size=len(self.vocab)).to(self.device) + state_dict = torch.load(self.model_path, map_location=self.device) + self.model.load_state_dict(state_dict) + self.model.eval() + + def predict(self, url: str | None) -> dict[str, Any]: + if not url: + return _unavailable_result(url, "target_url_missing") + + encoded = encode_url(str(url), self.vocab) + x = self.torch.tensor([encoded], dtype=self.torch.long).to(self.device) + + with self.torch.no_grad(): + logit = self.model(x) + prob = self.torch.sigmoid(logit).item() + + score = int(round(prob * 100)) + label = LABEL_MALICIOUS if prob >= self.threshold else LABEL_NORMAL + threats = [THREAT_LABEL] if label == LABEL_MALICIOUS else [] + + return { + "available": True, + "target_url": url, + "score": score, + "probability": prob, + "label": label, + "threshold": self.threshold, + "threats": threats, + "error": None, + } + + +@lru_cache(maxsize=1) +def get_predictor() -> CharCNNPredictor: + return CharCNNPredictor() + + +def predict_url(url: str | None) -> dict[str, Any]: + try: + return get_predictor().predict(url) + except Exception as exc: + return _unavailable_result(url, str(exc)) diff --git a/ml/charcnn/saved/README.md b/ml/charcnn/saved/README.md new file mode 100644 index 0000000..713bb8d --- /dev/null +++ b/ml/charcnn/saved/README.md @@ -0,0 +1,11 @@ +# CharCNN artifacts + +여기에 학습 완료된 CharCNN 산출물 두 개를 넣는다. + +```text +charcnn.pt +char_vocab.json +``` + +두 파일은 반드시 같은 학습에서 나온 한 쌍이어야 한다. +현재 ZIP에는 실제 모델 가중치가 포함되어 있지 않으므로, 이 파일을 넣기 전까지 `context.ml["charcnn"]["available"]`은 `False`로 나온다. diff --git a/ml/charcnn/saved/char_vocab.json b/ml/charcnn/saved/char_vocab.json new file mode 100644 index 0000000..a5160fc --- /dev/null +++ b/ml/charcnn/saved/char_vocab.json @@ -0,0 +1,63 @@ +{ + "": 0, + "": 1, + "h": 2, + "t": 3, + "p": 4, + "s": 5, + ":": 6, + "/": 7, + "b": 8, + "e": 9, + "n": 10, + "y": 11, + "7": 12, + "2": 13, + "6": 14, + ".": 15, + "w": 16, + "i": 17, + "x": 18, + "c": 19, + "o": 20, + "m": 21, + "-": 22, + "1": 23, + "r": 24, + "a": 25, + "l": 26, + "g": 27, + "d": 28, + "f": 29, + "u": 30, + "k": 31, + "j": 32, + "z": 33, + "q": 34, + "0": 35, + "8": 36, + "v": 37, + "3": 38, + "5": 39, + "9": 40, + "4": 41, + "?": 42, + "=": 43, + "&": 44, + "%": 45, + "(": 46, + ")": 47, + "_": 48, + "#": 49, + "@": 50, + ";": 51, + "+": 52, + "*": 53, + "!": 54, + "$": 55, + "[": 56, + "]": 57, + "~": 58, + ",": 59, + "'": 60 +} \ No newline at end of file diff --git a/ml/charcnn/saved/charcnn.pt b/ml/charcnn/saved/charcnn.pt new file mode 100644 index 0000000000000000000000000000000000000000..9269d3d0264dc18b1ed50e6c6659c2e679dca606 GIT binary patch literal 414972 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

e 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ƣ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 Y Date: Tue, 12 May 2026 20:24:12 +0900 Subject: [PATCH 69/77] =?UTF-8?q?*OTX=20=ED=99=94=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/external/req_otx.py | 52 ++++++++++++++++++++++++++++++++--- analyzers/req_api.py | 35 ----------------------- 2 files changed, 48 insertions(+), 39 deletions(-) diff --git a/analyzers/external/req_otx.py b/analyzers/external/req_otx.py index 2b08a38..a00d12d 100644 --- a/analyzers/external/req_otx.py +++ b/analyzers/external/req_otx.py @@ -1,6 +1,7 @@ import os import requests from urllib.parse import quote +import tldextract from core.config import statusData as stat @@ -19,17 +20,25 @@ # OTX 조회 메인 함수 def req_otx(target_url: str | None) -> dict: raw_otx = OTX_req_api(target_url) + + # 화이트리스트면 OTX 신고 이력이 있어도 일단 무시 + if is_whitelisted(target_url): + return { + "status": raw_otx.get("status"), + "is_threat": False, + "pulse_count": 0, + "threat_labels": [], + } + pulse_count, threat_labels = parse_OTX_result(raw_otx) - result = { + return { "status": raw_otx.get("status"), "is_threat": pulse_count > 0, "pulse_count": pulse_count, "threat_labels": threat_labels, } - return result - # OTX request def OTX_req_api(url: str | None) -> dict: @@ -108,4 +117,39 @@ def parse_OTX_result(raw: dict) -> tuple[int, list[str]]: if not otx_labels: otx_labels.add("OTX:SUSPICIOUS") - return count, list(otx_labels) \ No newline at end of file + return count, list(otx_labels) + + + + +# ========================================== +# 공통 유틸리티 및 화이트리스트 로직 +# ========================================== +def is_whitelisted(url: str | None) -> bool: + """URL의 도메인이 화이트리스트에 포함되어 있는지 확인""" + if not url: + return False + + WHITELIST_DOMAINS = { + "google.com", "google.co.kr", "naver.com", "daum.net", "yahoo.com", "bing.com", + "facebook.com", "instagram.com", "twitter.com", "linkedin.com", "youtube.com", "kakao.com", + "apple.com", "microsoft.com", "github.com", "stackoverflow.com", "cloudflare.com", "amazon.com", "aws.com" + } + + SAFE_SUFFIXES = { + "go.kr", "ac.kr", "re.kr", "mil.kr", "es.kr", "ms.kr", "hs.kr", "sc.kr", + "gov", "edu", "mil", "int" + } + + try: + ext = tldextract.extract(url) + domain = f"{ext.domain}.{ext.suffix}" + + if domain in WHITELIST_DOMAINS: + return True + if ext.suffix in SAFE_SUFFIXES: + return True + + return False + except Exception: + return False \ No newline at end of file diff --git a/analyzers/req_api.py b/analyzers/req_api.py index 3e04abf..81d8cd5 100644 --- a/analyzers/req_api.py +++ b/analyzers/req_api.py @@ -1,7 +1,5 @@ # 외부 api 조회 # 파이프라인에서는 해당 파일만 호출합니다. -import tldextract - from analyzers.external.req_gsb import req_gsb from analyzers.external.req_otx import req_otx from analyzers.external.req_whois import req_whois @@ -29,36 +27,3 @@ def req_api(context: AnalysisContext) -> dict: "otx": otx_result, "whois": whois_result } - - -# ========================================== -# 공통 유틸리티 및 화이트리스트 로직 -# ========================================== -def is_whitelisted(url: str) -> bool: - """URL의 도메인이 화이트리스트에 포함되어 있는지 확인""" - if not url: - return False - - WHITELIST_DOMAINS = { - "google.com", "google.co.kr", "naver.com", "daum.net", "yahoo.com", "bing.com", - "facebook.com", "instagram.com", "twitter.com", "linkedin.com", "youtube.com", "kakao.com", - "apple.com", "microsoft.com", "github.com", "stackoverflow.com", "cloudflare.com", "amazon.com", "aws.com" - } - - SAFE_SUFFIXES = { - "go.kr", "ac.kr", "re.kr", "mil.kr", "es.kr", "ms.kr", "hs.kr", "sc.kr", - "gov", "edu", "mil", "int" - } - - try: - ext = tldextract.extract(url) - domain = f"{ext.domain}.{ext.suffix}" - - if domain in WHITELIST_DOMAINS: - return True - if ext.suffix in SAFE_SUFFIXES: - return True - - return False - except Exception: - return False From 40e3f47487b28402d413df30b8d482a82aaca4b5 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Thu, 21 May 2026 21:31:38 +0900 Subject: [PATCH 70/77] =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=84=9C=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=ED=91=9C=EC=A4=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + analyzers/req_cert.py | 167 ++++++++++++++++---- core/config.py | 28 +++- services/builder.py | 59 +++++-- services/scoring.py | 356 +++++++++++++++++++++++++++++++----------- 5 files changed, 478 insertions(+), 133 deletions(-) diff --git a/.gitignore b/.gitignore index 1c39702..aa75ea3 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,4 @@ test-data test-data/ eval_results tools +test_results \ No newline at end of file diff --git a/analyzers/req_cert.py b/analyzers/req_cert.py index df9e953..4e1887b 100644 --- a/analyzers/req_cert.py +++ b/analyzers/req_cert.py @@ -1,10 +1,9 @@ -# 인증서 조회기 분리 -# 서버정보 취득에 실패해도 정상 작동합니다. import socket import ssl from core.context import AnalysisContext from core.config import statusData as stat +from core.config import certErrorData as Err CERT_TIMEOUT = 5 @@ -20,10 +19,10 @@ def req_cert(context: AnalysisContext) -> dict: def cert_req_api(hostname: str | None) -> dict: if not hostname: - return { - "status": stat.INVALID_INPUT, - "error": "hostname_missing" - } + return build_raw_cert_failure( + stat.CERT_INVALID_HOST, + Err.CERT_INVALID_HOST + ) try: ssl_context = ssl.create_default_context() @@ -37,50 +36,119 @@ def cert_req_api(hostname: str | None) -> dict: "data": cert } - except (socket.timeout, TimeoutError): - return { - "status": stat.TIMEOUT, - "error": "certificate_request_timeout" - } + except (socket.timeout, TimeoutError) as e: + status, error_code = classify_cert_error(e) + return build_raw_cert_failure(status, error_code) except ssl.SSLCertVerificationError as e: - return { - "status": stat.VERIFY_FAILED, - "error": str(e) - } + status, error_code = classify_cert_error(e) + return build_raw_cert_failure(status, error_code) except ssl.SSLError as e: - return { - "status": stat.SSL_ERROR, - "error": str(e) - } + status, error_code = classify_cert_error(e) + return build_raw_cert_failure(status, error_code) + + except socket.gaierror as e: + status, error_code = classify_cert_error(e) + return build_raw_cert_failure(status, error_code) except OSError as e: - return { - "status": stat.REQUEST_FAILED, - "error": str(e) - } + status, error_code = classify_cert_error(e) + return build_raw_cert_failure(status, error_code) except Exception as e: - return { - "status": stat.UNEXPECTED_ERROR, - "error": str(e) - } + status, error_code = classify_cert_error(e) + return build_raw_cert_failure(status, error_code) + + +# 예외 원문을 구체적인 라벨화 +# status_type, error_code +def classify_cert_error(error: Exception) -> tuple[str, str]: + message = str(error).lower() + + if isinstance(error, (socket.timeout, TimeoutError)): + return stat.CERT_TIMEOUT, Err.CERT_TIMEOUT + + if isinstance(error, ssl.SSLCertVerificationError): + if "self-signed certificate" in message: + return stat.CERT_UNTRUSTED, Err.CERT_SELF_SIGNED + + if "expired" in message or "certificate has expired" in message: + return stat.CERT_EXPIRED, Err.CERT_EXPIRED + + if "not yet valid" in message: + return stat.CERT_NOT_YET_VALID, Err.CERT_NOT_YET_VALID + + if ( + "hostname" in message + or "ip address mismatch" in message + or "doesn't match" in message + or "does not match" in message + ): + return stat.CERT_HOSTNAME_MISMATCH, Err.CERT_HOSTNAME_MISMATCH + + if "revoked" in message: + # statusData에는 revoked 전용 상태가 없으므로 큰 분류는 untrusted로 둡니다. + return stat.CERT_UNTRUSTED, Err.CERT_REVOKED + + if ( + "unable to get local issuer certificate" in message + or "unable to verify the first certificate" in message + or "certificate verify failed" in message + or "issuer certificate" in message + ): + return stat.CERT_UNTRUSTED, Err.CERT_UNTRUSTED + + return stat.CERT_UNTRUSTED, Err.CERT_UNTRUSTED + + if isinstance(error, ssl.SSLError): + return stat.CERT_SSL_ERROR, Err.CERT_SSL_ERROR + + if isinstance(error, socket.gaierror): + return stat.CERT_INVALID_HOST, Err.CERT_INVALID_HOST + + if isinstance(error, ConnectionRefusedError): + return stat.CERT_CONNECTION_FAILED, Err.CERT_CONNECTION_FAILED + + if isinstance(error, OSError): + if ( + "name or service not known" in message + or "nodename nor servname provided" in message + or "getaddrinfo failed" in message + ): + return stat.CERT_INVALID_HOST, Err.CERT_INVALID_HOST + + if "connection refused" in message: + return stat.CERT_CONNECTION_FAILED, Err.CERT_CONNECTION_FAILED + + if "network is unreachable" in message or "no route to host" in message: + return stat.CERT_CONNECTION_FAILED, Err.CERT_CONNECTION_FAILED + + return stat.CERT_LOOKUP_FAILED, Err.CERT_LOOKUP_FAILED + + return stat.CERT_UNKNOWN_ERROR, Err.CERT_UNKNOWN_ERROR + + +def build_raw_cert_failure(status: str, error_code: str) -> dict: + return { + "status": status, + "error": error_code + } def parse_cert_result(raw: dict) -> dict: if not isinstance(raw, dict): return build_cert_failure( - stat.UNEXPECTED_ERROR, - "invalid certificate response" + stat.CERT_UNKNOWN_ERROR, + Err.CERT_UNKNOWN_ERROR ) status = raw.get("status") if status != stat.SUCCESS: return build_cert_failure( - status or stat.UNEXPECTED_ERROR, - raw.get("error") + status or stat.CERT_UNKNOWN_ERROR, + normalize_error_code(raw.get("error")) ) cert = raw.get("data") or {} @@ -88,7 +156,7 @@ def parse_cert_result(raw: dict) -> dict: if not cert: return build_cert_failure( stat.NO_CERTIFICATE, - "peer certificate not available" + Err.CERT_NO_CERTIFICATE ) return { @@ -101,6 +169,33 @@ def parse_cert_result(raw: dict) -> dict: } +def normalize_error_code(error: str | None) -> str: + """ + cert_req_api 내부에서 만든 표준 error code만 최종 error에 남깁니다. + raw exception 문자열이 들어오면 CERT_UNKNOWN_ERROR로 치환합니다. + """ + valid_error_codes = { + Err.CERT_SELF_SIGNED, + Err.CERT_UNTRUSTED, + Err.CERT_EXPIRED, + Err.CERT_HOSTNAME_MISMATCH, + Err.CERT_NOT_YET_VALID, + Err.CERT_REVOKED, + Err.CERT_SSL_ERROR, + Err.CERT_INVALID_HOST, + Err.CERT_CONNECTION_FAILED, + Err.CERT_LOOKUP_FAILED, + Err.CERT_TIMEOUT, + Err.CERT_NO_CERTIFICATE, + Err.CERT_UNKNOWN_ERROR + } + + if isinstance(error, str) and error in valid_error_codes: + return error + + return Err.CERT_UNKNOWN_ERROR + + def build_cert_failure(status: str, error: str | None = None) -> dict: return { "valid": False, @@ -108,7 +203,7 @@ def build_cert_failure(status: str, error: str | None = None) -> dict: "validFrom": None, "validTo": None, "status_type": status, - "error": [error or status] + "error": [normalize_error_code(error)] } @@ -123,6 +218,12 @@ def build_cert_result(parsed: dict) -> dict: } base.update(parsed) + + if not isinstance(base.get("error"), list): + base["error"] = [normalize_error_code(base.get("error"))] + else: + base["error"] = [normalize_error_code(error) for error in base.get("error", [])] + return base diff --git a/core/config.py b/core/config.py index adec3ca..84079c5 100644 --- a/core/config.py +++ b/core/config.py @@ -23,7 +23,33 @@ class statusData: VERIFY_FAILED = "verify_failed" SSL_ERROR = "ssl_error" - + # 인증서 신뢰 불가한 경우 + CERT_UNTRUSTED = "cert_untrusted" + CERT_EXPIRED = "cert_expired" + CERT_HOSTNAME_MISMATCH = "cert_hostname_mismatch" + CERT_NOT_YET_VALID = "cert_not_yet_valid" + CERT_LOOKUP_FAILED = "cert_lookup_failed" + CERT_TIMEOUT = "cert_timeout" + CERT_INVALID_HOST = "cert_invalid_host" + CERT_UNKNOWN_ERROR = "cert_unknown_error" + CERT_SSL_ERROR = "cert_ssl_error" + CERT_CONNECTION_FAILED = "cert_connection_failed" + +# 인증서 error 라벨 +class certErrorData: + CERT_SELF_SIGNED = "CERT_SELF_SIGNED" + CERT_UNTRUSTED = "CERT_UNTRUSTED" + CERT_EXPIRED = "CERT_EXPIRED" + CERT_HOSTNAME_MISMATCH = "CERT_HOSTNAME_MISMATCH" + CERT_NOT_YET_VALID = "CERT_NOT_YET_VALID" + CERT_REVOKED = "CERT_REVOKED" + CERT_SSL_ERROR = "CERT_SSL_ERROR" + CERT_INVALID_HOST = "CERT_INVALID_HOST" + CERT_CONNECTION_FAILED = "CERT_CONNECTION_FAILED" + CERT_LOOKUP_FAILED = "CERT_LOOKUP_FAILED" + CERT_TIMEOUT = "CERT_TIMEOUT" + CERT_NO_CERTIFICATE = "CERT_NO_CERTIFICATE" + CERT_UNKNOWN_ERROR = "CERT_UNKNOWN_ERROR" # context 접근 시 필드명 목록 # !!context 변경 시 반드시 함께 변경해야 합니다!! diff --git a/services/builder.py b/services/builder.py index b1de762..7e6b7ce 100644 --- a/services/builder.py +++ b/services/builder.py @@ -8,6 +8,42 @@ +def safe_dict(value) -> dict: + return value if isinstance(value, dict) else {} + + +def safe_int(value, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def clamp_score(value) -> int: + try: + score = int(round(float(value))) + except (TypeError, ValueError): + score = 0 + return max(0, min(100, score)) + + +def normalize_list(value) -> list: + if value is None: + return [] + if isinstance(value, list): + return [item for item in value if item is not None] + if isinstance(value, (tuple, set)): + return [item for item in value if item is not None] + return [value] + + +def normalize_risk_level(value: str | None) -> str: + if value in (lev.THREAT, lev.CAUTION, lev.SAFE): + return value + return lev.SAFE + + + # threats 작성 함수 def build_threats(context: AnalysisContext) -> list[str]: threats = [] @@ -27,9 +63,7 @@ def build_threats(context: AnalysisContext) -> list[str]: if label not in threats: threats.append(label) - cert_errors = context.certificate.get(ctx.Cert_error, []) - if isinstance(cert_errors, str): - cert_errors = [cert_errors] + cert_errors = normalize_list(context.certificate.get(ctx.Cert_error, [])) for error in cert_errors: if error not in threats: @@ -40,8 +74,9 @@ def build_threats(context: AnalysisContext) -> list[str]: # externalApi-result 작성 함수 def build_external_result(context: AnalysisContext) -> str: - safe_browsing = context.external.get(ctx.EX_GSB, {}) - otx = context.external.get(ctx.EX_OTX, {}) + external = safe_dict(context.external) + safe_browsing = safe_dict(external.get(ctx.EX_GSB, {})) + otx = safe_dict(external.get(ctx.EX_OTX, {})) whois = context.external.get(ctx.EX_WHOIS, {}) # 1. 위협 탐지 우선 @@ -151,8 +186,9 @@ def build_domain_age(context: AnalysisContext) -> int | None: def build_result(context: AnalysisContext) -> AnalysisResult: - safe_browsing = context.external.get(ctx.EX_GSB, {}) - otx = context.external.get(ctx.EX_OTX, {}) + external = safe_dict(context.external) + safe_browsing = safe_dict(external.get(ctx.EX_GSB, {})) + otx = safe_dict(external.get(ctx.EX_OTX, {})) result = AnalysisResult( guestUuid=context.request.get("guest_Uuid", ""), @@ -169,7 +205,7 @@ def build_result(context: AnalysisContext) -> AnalysisResult: result.reportCount = build_report_count(context) result.domainAge = build_domain_age(context) result.threats = build_threats(context) - result.ml = context.ml or {} + result.ml = safe_dict(context.ml) result.externalApi["checked"] = ( safe_browsing.get(ctx.GSB_status) is not None @@ -184,7 +220,7 @@ def build_result(context: AnalysisContext) -> AnalysisResult: or context.url.get("normalized_url") or context.request.get("original_url") ) - result.redirect["redirectCount"] = int(context.redirect.get("redirect_count", 0) or 0) + result.redirect["redirectCount"] = safe_int(context.redirect.get("redirect_count", 0), 0) result.serverInfo["type"] = context.external.get("server_type") result.serverInfo["location"] = context.external.get("server_location") @@ -193,8 +229,9 @@ def build_result(context: AnalysisContext) -> AnalysisResult: result.serverInfo["certificate"]["validFrom"] = context.certificate.get("validFrom") result.serverInfo["certificate"]["validTo"] = context.certificate.get("validTo") - result.score = int(context.scoring.get("score", 0) or 0) - result.riskLevel = context.scoring.get("risk_level") or "SAFE" + scoring_result = safe_dict(context.scoring) + result.score = clamp_score(scoring_result.get("score", 0)) + result.riskLevel = normalize_risk_level(scoring_result.get("risk_level")) return result diff --git a/services/scoring.py b/services/scoring.py index 5755899..d070b42 100644 --- a/services/scoring.py +++ b/services/scoring.py @@ -1,13 +1,22 @@ # 검사 결과에 기반해 점수를 매기는 로직입니다. from core.context import AnalysisContext +from core.config import ctxData as ctx, statusData as stat, riskLevelData as lev +from core.config import certErrorData as Err + # 메인 점수 계산 함수 def score_analysis(context: AnalysisContext) -> dict: - internal_score, internal_reasons = score_internal(context) - ml_score = score_ml(context) - external_score, external_reasons = score_external(context) + internal_score, internal_reasons = safe_score_part( + lambda: score_internal(context), + fallback_reason="internal_score_failed" + ) + ml_score = safe_score_ml(context) + external_score, external_reasons = safe_score_part( + lambda: score_external(context), + fallback_reason="external_score_failed" + ) final_score = round( internal_score * 0.45 + @@ -15,40 +24,55 @@ def score_analysis(context: AnalysisContext) -> dict: external_score * 0.20 ) - safe_browsing = context.external.get("safe_browsing", {}) - otx = context.external.get("otx", {}) + safe_browsing = safe_dict(context.external).get(ctx.EX_GSB, {}) + otx = safe_dict(context.external).get(ctx.EX_OTX, {}) - if safe_browsing.get("is_threat"): + # 외부 API가 명확히 위협이라고 한 경우에는 최소 점수를 보장한다. + if safe_dict(safe_browsing).get(ctx.GSB_is_threat): final_score = max(final_score, 85) - if otx.get("is_threat"): + if safe_dict(otx).get(ctx.OTX_is_threat): final_score = max(final_score, 65) + final_score = clamp_score(final_score) risk_level = decide_risk_level(final_score, safe_browsing, otx) + reason_summary = unique_values(internal_reasons + external_reasons) + matched_rules = collect_matched_rules(context) + return build_scoring_result( internal_score=internal_score, ml_score=ml_score, external_score=external_score, score=final_score, risk_level=risk_level, - reason_summary=internal_reasons + external_reasons, - matched_rules=collect_matched_rules(context) + reason_summary=reason_summary, + matched_rules=matched_rules ) +# 각 점수 파트가 터져도 최종 scoring은 계속 진행되도록 방어한다. +def safe_score_part(func, fallback_reason: str) -> tuple[int, list[str]]: + try: + score, reasons = func() + return clamp_score(score), normalize_list(reasons) + except Exception: + return 0, [fallback_reason] + + # 내부 로직 결과 점수 -def score_internal(context: AnalysisContext) -> tuple[int, list]: +def score_internal(context: AnalysisContext) -> tuple[int, list[str]]: score = 0 reasons = [] - shortened = context.detection.get("shortened_url", {}) - if shortened.get("detected"): + detection = safe_dict(context.detection) + shortened = safe_dict(detection.get(ctx.Det_short)) + if shortened.get(ctx.Short_det): score += 15 reasons.append("shortened_url") - redirect = context.redirect - redirect_count = redirect.get("redirect_count", 0) + redirect = safe_dict(context.redirect) + redirect_count = safe_int(redirect.get(ctx.Red_count), 0) if redirect_count == 1: score += 5 @@ -60,19 +84,17 @@ def score_internal(context: AnalysisContext) -> tuple[int, list]: score += 15 reasons.append("redirect_3_or_more") - if redirect.get("loop_detected"): + if bool(redirect.get(ctx.Red_loop_det)): score += 30 reasons.append("redirect_loop_detected") - status_type = redirect.get("status_type") - if status_type: - _, label = status_type - if label == "too_many_redirects": - score += 25 - reasons.append("too_many_redirects") + redirect_status = status_value(redirect.get(ctx.Red_status)) + if redirect_status == "too_many_redirects": + score += 25 + reasons.append("too_many_redirects") - rule_based = context.detection.get("rule_based", {}) - flags = rule_based.get("flags", []) + rule_based = safe_dict(detection.get(ctx.Det_rule)) + flags = normalize_list(rule_based.get(ctx.Rule_flags)) flag_scores = { "suspicious_query_param_detected": 12, @@ -88,60 +110,65 @@ def score_internal(context: AnalysisContext) -> tuple[int, list]: score += flag_scores.get(flag, 0) reasons.append(flag) - return min(score, 100), reasons + return clamp_score(score), reasons -def score_ml(context: AnalysisContext) -> int: - score = context.ml.get("score") - - if score is None: # None방어 추가 +def safe_score_ml(context: AnalysisContext) -> int: + try: + return score_ml(context) + except Exception: return 0 - try: - score = int(round(float(score))) - except (TypeError, ValueError): + +def score_ml(context: AnalysisContext) -> int: + ml_result = safe_dict(context.ml) + score = ml_result.get(ctx.Ml_score) + + # CharCNN predictor가 available=false인 경우에는 실패가 아니라 "ML 판단 없음"으로 본다. + charcnn = safe_dict(ml_result.get("charcnn")) + if charcnn and charcnn.get("available") is False: return 0 - return max(0, min(100, score)) + return clamp_score(safe_float(score, 0.0)) # 외부 조회 결과 점수 -def score_external(context: AnalysisContext) -> tuple[int, list]: +def score_external(context: AnalysisContext) -> tuple[int, list[str]]: score = 0 reasons = [] - external = context.external + external = safe_dict(context.external) + + safe_browsing = safe_dict(external.get(ctx.EX_GSB)) + if safe_browsing.get(ctx.GSB_is_threat): + score += 40 + reasons.append("gsb_threat_detected") - otx = external.get("otx", {}) - pulse_count = otx.get("pulse_count", 0) + otx = safe_dict(external.get(ctx.EX_OTX)) + pulse_count = safe_int(otx.get(ctx.OTX_pulse_count), 0) - # otx 반영 - if otx.get("is_threat"): + if otx.get(ctx.OTX_is_threat): score += 25 reasons.append("otx_threat_detected") - if isinstance(pulse_count, int): - if 1 <= pulse_count <= 2: - score += 5 - reasons.append("otx_count_low") - elif 3 <= pulse_count <= 5: - score += 10 - reasons.append("otx_count_mid") - elif pulse_count >= 6: - score += 15 - reasons.append("otx_count_high") + if 1 <= pulse_count <= 2: + score += 5 + reasons.append("otx_count_low") + elif 3 <= pulse_count <= 5: + score += 10 + reasons.append("otx_count_mid") + elif pulse_count >= 6: + score += 15 + reasons.append("otx_count_high") - # 인증서 유효 여부 - cert_valid = context.certificate.get("valid") - if cert_valid is False: - score += 25 - reasons.append("invalid_certificate") + cert_score, cert_reasons = score_certificate(context) + score += cert_score + reasons.extend(cert_reasons) - # whois 반영 - whois = external.get("whois", {}) - domain_age_days = whois.get("domain_age_days") + whois = safe_dict(external.get(ctx.EX_WHOIS)) + domain_age_days = safe_int(whois.get(ctx.WHO_age), 0) - if isinstance(domain_age_days, int) and domain_age_days > 0: + if domain_age_days > 0: if domain_age_days <= 30: score += 15 reasons.append("very_young_domain") @@ -149,56 +176,135 @@ def score_external(context: AnalysisContext) -> tuple[int, list]: score += 8 reasons.append("young_domain") - # 서버 유형 - server_type = external.get("server_type") - if server_type in (None, "", "unknown"): - score += 10 + server_type = external.get(ctx.Ex_server_type) + server_status = external.get(ctx.Ex_server_status) + + # 서버 타입이 실제로 조회됐는데 unknown/빈 값이면 약한 가중치만 준다. + # 아무 조회도 안 된 기본 None 상태는 점수로 보지 않는다. + if server_status is not None and server_type in (None, "", "unknown"): + score += 3 reasons.append("unknown_server_type") - return min(score, 100), reasons + return clamp_score(score), reasons + + +def score_certificate(context: AnalysisContext) -> tuple[int, list[str]]: + certificate = safe_dict(context.certificate) + status = certificate.get(ctx.Cert_status) + errors = normalize_list(certificate.get(ctx.Cert_error)) + + # 인증서 검사를 아직 수행하지 않은 기본 상태. + if status is None and not errors: + return 0, [] + + # 정상 인증서. + if certificate.get(ctx.Cert_valid) is True or status in (stat.SUCCESS, "cert_success"): + return 0, [] + + risky_status_scores = { + stat.CERT_UNTRUSTED: 15, + stat.CERT_EXPIRED: 15, + stat.CERT_HOSTNAME_MISMATCH: 12, + stat.CERT_NOT_YET_VALID: 10, + } + + weak_status_scores = { + stat.CERT_SSL_ERROR: 5, + stat.CERT_LOOKUP_FAILED: 3, + stat.CERT_CONNECTION_FAILED: 3, + stat.CERT_TIMEOUT: 2, + stat.CERT_INVALID_HOST: 0, + stat.CERT_UNKNOWN_ERROR: 3, + stat.NO_CERTIFICATE: 5, + } + + error_scores = { + Err.CERT_SELF_SIGNED: 15, + Err.CERT_UNTRUSTED: 15, + Err.CERT_EXPIRED: 15, + Err.CERT_HOSTNAME_MISMATCH: 12, + Err.CERT_NOT_YET_VALID: 10, + Err.CERT_REVOKED: 15, + Err.CERT_SSL_ERROR: 5, + Err.CERT_LOOKUP_FAILED: 3, + Err.CERT_CONNECTION_FAILED: 3, + Err.CERT_TIMEOUT: 2, + Err.CERT_INVALID_HOST: 0, + Err.CERT_NO_CERTIFICATE: 5, + Err.CERT_UNKNOWN_ERROR: 3, + } + + score_candidates = [] + + if status in risky_status_scores: + score_candidates.append(risky_status_scores[status]) + elif status in weak_status_scores: + score_candidates.append(weak_status_scores[status]) + elif certificate.get(ctx.Cert_valid) is False: + # status가 없거나 모르는 값인데 valid만 False인 경우. 과가중하지 않는다. + score_candidates.append(3) + + for error in errors: + score_candidates.append(error_scores.get(error, 3)) + + score = max(score_candidates) if score_candidates else 0 + + reasons = [] + if score > 0: + # reason에는 raw error가 아니라 표준 status/error 라벨만 남긴다. + if status: + reasons.append(str(status)) + reasons.extend(str(error) for error in errors) + + return clamp_score(score), unique_values(reasons) # 위험도 반환 def decide_risk_level(final_score: int, safe_browsing: dict, otx: dict) -> str: - if safe_browsing.get("is_threat"): - return "THREAT" + final_score = clamp_score(final_score) + safe_browsing = safe_dict(safe_browsing) + otx = safe_dict(otx) + + if safe_browsing.get(ctx.GSB_is_threat): + return lev.THREAT - if otx.get("is_threat") and final_score >= 60: - return "THREAT" + if otx.get(ctx.OTX_is_threat) and final_score >= 60: + return lev.THREAT if final_score >= 60: - return "THREAT" + return lev.THREAT if final_score >= 30: - return "CAUTION" - return "SAFE" + return lev.CAUTION + return lev.SAFE -def collect_matched_rules(context: AnalysisContext) -> list: +def collect_matched_rules(context: AnalysisContext) -> list[str]: matched = [] - shortened = context.detection.get("shortened_url", {}) - if shortened.get("detected"): + detection = safe_dict(context.detection) + + shortened = safe_dict(detection.get(ctx.Det_short)) + if shortened.get(ctx.Short_det): matched.append("shortened_url") - rule_based = context.detection.get("rule_based", {}) - matched.extend(rule_based.get("flags", [])) + rule_based = safe_dict(detection.get(ctx.Det_rule)) + matched.extend(normalize_list(rule_based.get(ctx.Rule_flags))) + + external = safe_dict(context.external) - safe_browsing = context.external.get("safe_browsing", {}) - matched.extend(safe_browsing.get("threat_labels", [])) + safe_browsing = safe_dict(external.get(ctx.EX_GSB)) + matched.extend(normalize_list(safe_browsing.get(ctx.GSB_threats))) - otx = context.external.get("otx", {}) - matched.extend(otx.get("threat_labels", [])) + otx = safe_dict(external.get(ctx.EX_OTX)) + matched.extend(normalize_list(otx.get(ctx.OTX_threats))) - ml_result = context.ml or {} - matched.extend(ml_result.get("threats", [])) + ml_result = safe_dict(context.ml) + matched.extend(normalize_list(ml_result.get(ctx.Ml_threats))) - # 중복 제거 - unique = [] - for item in matched: - if item not in unique: - unique.append(item) + certificate = safe_dict(context.certificate) + matched.extend(normalize_list(certificate.get(ctx.Cert_error))) - return unique + return unique_values(matched) def build_scoring_result(**overrides) -> dict: @@ -207,7 +313,7 @@ def build_scoring_result(**overrides) -> dict: "ml_score": 0, "external_score": 0, "score": 0, - "risk_level": None, + "risk_level": lev.SAFE, "reason_summary": [], "matched_rules": [] } @@ -217,4 +323,78 @@ def build_scoring_result(**overrides) -> dict: raise KeyError(f"허용되지 않은 scoring 필드: {key}") result.update(overrides) - return result \ No newline at end of file + + result["internal_score"] = clamp_score(result.get("internal_score")) + result["ml_score"] = clamp_score(result.get("ml_score")) + result["external_score"] = clamp_score(result.get("external_score")) + result["score"] = clamp_score(result.get("score")) + + if result.get("risk_level") not in (lev.THREAT, lev.CAUTION, lev.SAFE): + result["risk_level"] = lev.SAFE + + result["reason_summary"] = unique_values(normalize_list(result.get("reason_summary"))) + result["matched_rules"] = unique_values(normalize_list(result.get("matched_rules"))) + + return result + + +def safe_dict(value) -> dict: + return value if isinstance(value, dict) else {} + + +def normalize_list(value) -> list: + if value is None: + return [] + + if isinstance(value, list): + return [item for item in value if item is not None] + + if isinstance(value, tuple) or isinstance(value, set): + return [item for item in value if item is not None] + + return [value] + + +def unique_values(values: list) -> list: + result = [] + + for value in values: + if value is None: + continue + + if value not in result: + result.append(value) + + return result + + +def safe_int(value, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def safe_float(value, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def clamp_score(value) -> int: + return max(0, min(100, int(round(safe_float(value, 0.0))))) + + +def status_value(status) -> str | None: + if status is None: + return None + + if isinstance(status, (list, tuple)): + if len(status) >= 2: + return str(status[1]).lower() + if len(status) == 1: + return str(status[0]).lower() + return None + + return str(status).lower() \ No newline at end of file From 4a145f0a3809ffd5d6df6ff4d13b843f75356d9e Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 29 May 2026 09:12:20 +0900 Subject: [PATCH 71/77] =?UTF-8?q?XGB=EB=AA=A8=EB=8D=B8=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20threats?= =?UTF-8?q?=EC=97=90=20=EA=B0=81=20=EA=B2=80=EC=82=AC=EA=B8=B0=EB=B3=84=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EB=9D=BC=EB=B2=A8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/ai_analyze.py | 119 +++++++++++++++++++++++-- analyzers/external/req_otx.py | 60 ++++++++----- analyzers/external/req_whois.py | 75 ++++++++++------ ml/xgb/__init__.py | 1 + ml/xgb/config.py | 45 ++++++++++ ml/xgb/feat_extractor.py | 136 +++++++++++++++++++++++++++++ ml/xgb/feat_schema.py | 45 ++++++++++ ml/xgb/predictor.py | 98 +++++++++++++++++++++ ml/xgb/saved/xgb_model.joblib | Bin 0 -> 224594 bytes services/builder.py | 150 +++++++++++++++++++++++++++++--- services/response.py | 2 +- services/scoring.py | 22 ++++- 12 files changed, 681 insertions(+), 72 deletions(-) create mode 100644 ml/xgb/__init__.py create mode 100644 ml/xgb/config.py create mode 100644 ml/xgb/feat_extractor.py create mode 100644 ml/xgb/feat_schema.py create mode 100644 ml/xgb/predictor.py create mode 100644 ml/xgb/saved/xgb_model.joblib diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py index 312fcb2..41ca14a 100644 --- a/analyzers/ai_analyze.py +++ b/analyzers/ai_analyze.py @@ -1,7 +1,13 @@ # pre-trained ML 모델을 호출해 분석을 진행합니다. -# 현재 연결 대상: CharCNN 추론 전용 모듈 +# 현재 연결 대상: CharCNN + FEB/XGB score-level fusion from core.context import AnalysisContext -from ml.charcnn.predictor import predict_url +from ml.charcnn.predictor import predict_url as predict_charcnn_url +from ml.xgb.predictor import predict_context as predict_xgb_context + + +FUSION_METHOD = "weighted_average" +XGB_WEIGHT = 0.65 +CHARCNN_WEIGHT = 0.35 # ML 입력 URL 결정 함수 @@ -20,23 +26,110 @@ def resolve_ml_target_url(context: AnalysisContext) -> str | None: def ai_analyze(context: AnalysisContext) -> dict: target_url = resolve_ml_target_url(context) - charcnn_result = run_ml_inference(target_url) + + charcnn_result = run_charcnn_inference(target_url) + xgb_result = run_xgb_inference(context) + fusion_result = fuse_ml_scores(xgb_result, charcnn_result) return build_ml_result( - score=charcnn_result.get("score", 0), - threats=charcnn_result.get("threats", []), + score=fusion_result.get("score", 0), + threats=collect_ml_threats(xgb_result, charcnn_result, fusion_result), charcnn=charcnn_result, + xgb=xgb_result, + fusion=fusion_result, ) -def run_ml_inference(target_url: str | None) -> dict: - return predict_url(target_url) +def run_charcnn_inference(target_url: str | None) -> dict: + return predict_charcnn_url(target_url) + + +def run_xgb_inference(context: AnalysisContext) -> dict: + return predict_xgb_context(context) + + +def is_available(result: dict | None) -> bool: + return isinstance(result, dict) and result.get("available") is True + + +def safe_score(result: dict | None) -> int | None: + if not is_available(result): + return None + + try: + return max(0, min(100, int(round(float(result.get("score", 0)))))) + except (TypeError, ValueError): + return None + + +def fuse_ml_scores(xgb: dict, charcnn: dict) -> dict: + xgb_score = safe_score(xgb) + charcnn_score = safe_score(charcnn) + + contributors = [] + + if xgb_score is not None: + contributors.append("xgb") + if charcnn_score is not None: + contributors.append("charcnn") + + if xgb_score is not None and charcnn_score is not None: + # 현재 CharCNN이 일부 정상 URL에도 매우 높은 점수를 내는 경향이 있어 + # XGB를 더 강하게 반영하는 가중 평균으로 시작한다. + score = round(xgb_score * XGB_WEIGHT + charcnn_score * CHARCNN_WEIGHT) + method = FUSION_METHOD + weights = {"xgb": XGB_WEIGHT, "charcnn": CHARCNN_WEIGHT} + elif xgb_score is not None: + score = xgb_score + method = "xgb_only" + weights = {"xgb": 1.0, "charcnn": 0.0} + elif charcnn_score is not None: + score = charcnn_score + method = "charcnn_only" + weights = {"xgb": 0.0, "charcnn": 1.0} + else: + score = 0 + method = "unavailable" + weights = {"xgb": 0.0, "charcnn": 0.0} + + return { + "available": bool(contributors), + "method": method, + "weights": weights, + "contributors": contributors, + "score": max(0, min(100, int(score))), + } + + +def collect_ml_threats(xgb: dict, charcnn: dict, fusion: dict) -> list[str]: + # top-level ml.threats는 submodel 원시 판단이 아니라 fusion 기준 판단으로 둔다. + # 개별 모델의 원시 threats는 ml.xgb / ml.charcnn 안에 그대로 남긴다. + try: + fusion_score = int(fusion.get("score", 0)) + except (TypeError, ValueError): + fusion_score = 0 + + if fusion_score < 50: + return [] + + threats = [] + for result in (xgb, charcnn): + if not isinstance(result, dict): + continue + + for threat in result.get("threats", []) or []: + if threat not in threats: + threats.append(threat) + + return threats def build_ml_result( score: int = 0, threats: list | None = None, charcnn: dict | None = None, + xgb: dict | None = None, + fusion: dict | None = None, ) -> dict: if threats is None: threats = [] @@ -50,4 +143,16 @@ def build_ml_result( "label": "unknown", "threats": [], }, + "xgb": xgb or { + "available": False, + "score": 0, + "label": "unknown", + "threats": [], + }, + "fusion": fusion or { + "available": False, + "method": "unavailable", + "score": 0, + "contributors": [], + }, } diff --git a/analyzers/external/req_otx.py b/analyzers/external/req_otx.py index a00d12d..fbd5a57 100644 --- a/analyzers/external/req_otx.py +++ b/analyzers/external/req_otx.py @@ -1,7 +1,11 @@ import os import requests -from urllib.parse import quote -import tldextract +from urllib.parse import quote, urlsplit + +try: + import tldextract +except ImportError: + tldextract = None from core.config import statusData as stat @@ -13,11 +17,9 @@ "BOTNET", "SPAM", "C2", -] # 위험 유형 라벨 - +] -# OTX 조회 메인 함수 def req_otx(target_url: str | None) -> dict: raw_otx = OTX_req_api(target_url) @@ -40,7 +42,6 @@ def req_otx(target_url: str | None) -> dict: } -# OTX request def OTX_req_api(url: str | None) -> dict: if not url: return {"status": stat.INVALID_INPUT, "data": None} @@ -82,7 +83,6 @@ def OTX_req_api(url: str | None) -> dict: } -# OTX request URL/header 생성 def OTX_req_build(url: str) -> tuple[str, dict]: encoded_url = quote(url, safe="") endpoint = f"https://otx.alienvault.com/api/v1/indicators/url/{encoded_url}/general" @@ -90,7 +90,6 @@ def OTX_req_build(url: str) -> tuple[str, dict]: return endpoint, headers -# OTX 응답에서 pulse count와 threat label 추출 def parse_OTX_result(raw: dict) -> tuple[int, list[str]]: if raw.get("status") != stat.SUCCESS: return 0, [] @@ -99,7 +98,7 @@ def parse_OTX_result(raw: dict) -> tuple[int, list[str]]: pulse_info = data.get("pulse_info", {}) count = pulse_info.get("count", 0) or 0 - otx_labels = set() # 중복 방지 + otx_labels = set() if count > 0: pulses = pulse_info.get("pulses", []) @@ -113,23 +112,21 @@ def parse_OTX_result(raw: dict) -> tuple[int, list[str]]: if tag_upper in TARGET_THREAT_KEYWORDS: otx_labels.add(f"OTX:{tag_upper}") - # 신고 내역(count > 0)있음, 쓸만한 태그 없음 -> 기본 라벨 if not otx_labels: otx_labels.add("OTX:SUSPICIOUS") return count, list(otx_labels) - - -# ========================================== -# 공통 유틸리티 및 화이트리스트 로직 -# ========================================== def is_whitelisted(url: str | None) -> bool: - """URL의 도메인이 화이트리스트에 포함되어 있는지 확인""" + """URL의 도메인이 화이트리스트에 포함되어 있는지 확인. + + tldextract가 설치되지 않은 환경에서도 import 자체가 실패하지 않도록 + stdlib 기반 fallback을 둔다. + """ if not url: return False - + WHITELIST_DOMAINS = { "google.com", "google.co.kr", "naver.com", "daum.net", "yahoo.com", "bing.com", "facebook.com", "instagram.com", "twitter.com", "linkedin.com", "youtube.com", "kakao.com", @@ -142,14 +139,31 @@ def is_whitelisted(url: str | None) -> bool: } try: - ext = tldextract.extract(url) - domain = f"{ext.domain}.{ext.suffix}" - + domain, suffix = extract_domain_and_suffix(url) + if domain in WHITELIST_DOMAINS: return True - if ext.suffix in SAFE_SUFFIXES: + if suffix in SAFE_SUFFIXES: return True - + return False except Exception: - return False \ No newline at end of file + return False + + +def extract_domain_and_suffix(url: str) -> tuple[str, str]: + if tldextract is not None: + ext = tldextract.extract(url) + return f"{ext.domain}.{ext.suffix}", ext.suffix + + candidate = url if "://" in url else f"https://{url}" + hostname = (urlsplit(candidate).hostname or "").lower().rstrip(".") + parts = hostname.split(".") if hostname else [] + + if len(parts) >= 3 and ".".join(parts[-2:]) in {"co.kr", "go.kr", "ac.kr", "or.kr", "ne.kr"}: + return ".".join(parts[-3:]), ".".join(parts[-2:]) + + if len(parts) >= 2: + return ".".join(parts[-2:]), parts[-1] + + return hostname, parts[-1] if parts else "" diff --git a/analyzers/external/req_whois.py b/analyzers/external/req_whois.py index 549c803..82500b9 100644 --- a/analyzers/external/req_whois.py +++ b/analyzers/external/req_whois.py @@ -1,64 +1,90 @@ -import whois -import tldextract from datetime import datetime, date +from urllib.parse import urlsplit + +try: + import whois +except ImportError: + whois = None + +try: + import tldextract +except ImportError: + tldextract = None from core.config import statusData as stat -# whois 조회 메인 함수 def req_whois(target_url: str | None) -> dict: raw_whois = WHOIS_req_api(target_url) domain_date = parse_WHOIS_result(raw_whois) - result = { + return { "status": raw_whois.get("status"), "creation_date": domain_date, "domain_age_days": calculate_domain_age(domain_date) } - - return result -# whois request def WHOIS_req_api(url: str | None) -> dict: - if not url: return {"status": stat.INVALID_INPUT, "data": None} - + if not url: + return {"status": stat.INVALID_INPUT, "data": None} + + if whois is None: + return {"status": stat.REQUEST_FAILED, "data": None, "error": "python-whois_not_installed"} + try: - ext = tldextract.extract(url) + domain = extract_registered_domain(url) - if not ext.domain or not ext.suffix: + if not domain: return {"status": stat.INVALID_INPUT, "data": None} - domain = f"{ext.domain}.{ext.suffix}" - domain_info = whois.whois(domain) return {"status": stat.SUCCESS, "data": domain_info} except Exception as e: return {"status": stat.REQUEST_FAILED, "data": None, "error": str(e)} -# 도메인의 생성일만 추출 +def extract_registered_domain(url: str) -> str | None: + if tldextract is not None: + ext = tldextract.extract(url) + if not ext.domain or not ext.suffix: + return None + return f"{ext.domain}.{ext.suffix}" + + candidate = url if "://" in url else f"https://{url}" + hostname = (urlsplit(candidate).hostname or "").lower().rstrip(".") + if not hostname: + return None + + parts = hostname.split(".") + if len(parts) < 2: + return None + + if len(parts) >= 3 and ".".join(parts[-2:]) in {"co.kr", "go.kr", "ac.kr", "or.kr", "ne.kr"}: + return ".".join(parts[-3:]) + + return ".".join(parts[-2:]) + + def parse_WHOIS_result(raw: dict) -> str | None: if raw.get("status") != stat.SUCCESS: return None - + data = raw.get("data") if not data: return None - # python-whois 객체 특성(dict와 object 혼용) 방어 로직 creation_date = data.get("creation_date") if isinstance(data, dict) else getattr(data, "creation_date", None) - + if isinstance(creation_date, list): creation_date = next((item for item in creation_date if item), None) - + if isinstance(creation_date, datetime): return creation_date.strftime("%Y-%m-%d") - - # creation_date가 문자열일 시 처리 추가 + if isinstance(creation_date, date): return creation_date.isoformat() - + if isinstance(creation_date, str): creation_date = creation_date.strip() @@ -67,11 +93,10 @@ def parse_WHOIS_result(raw: dict) -> str | None: return datetime.strptime(creation_date[:10], fmt).strftime("%Y-%m-%d") except ValueError: pass - + return None -# 검사일 기준 도메인 생성일로부터 경과한 일 수 def calculate_domain_age(creation_date: str | None) -> int | None: if not creation_date: return None @@ -79,6 +104,6 @@ def calculate_domain_age(creation_date: str | None) -> int | None: try: created = datetime.strptime(creation_date, "%Y-%m-%d").date() today = datetime.now().date() - return max((today - created).days, 0) # max: 날짜가 미래로 들어오는 오류가 있을 경우 방어용 + return max((today - created).days, 0) except ValueError: - return None \ No newline at end of file + return None diff --git a/ml/xgb/__init__.py b/ml/xgb/__init__.py new file mode 100644 index 0000000..a4cc26f --- /dev/null +++ b/ml/xgb/__init__.py @@ -0,0 +1 @@ +"""FEB + XGB inference package for the analysis engine.""" diff --git a/ml/xgb/config.py b/ml/xgb/config.py new file mode 100644 index 0000000..9ac1634 --- /dev/null +++ b/ml/xgb/config.py @@ -0,0 +1,45 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent +SAVED_DIR = BASE_DIR / "saved" +XGB_MODEL_PATH = SAVED_DIR / "xgb_model.joblib" + +XGB_THRESHOLD = 0.5 +HIGH_RISK_SCORE_THRESHOLD = 80 + +XGB_THREAT_LABEL = "XGB:SUSPICIOUS_URL_FEATURES" +XGB_HIGH_RISK_THREAT_LABEL = "XGB:HIGH_RISK_URL" + +SENSITIVE_KEYWORDS = [ + "login", + "verify", + "secure", + "account", + "update", + "confirm", + "admin", + "signin", + "password", + "bank", + "payment", +] + +FREE_HOSTING_KEYWORDS = [ + "000webhost", + "github.io", + "pages.dev", + "netlify.app", + "vercel.app", + "firebaseapp.com", + "web.app", +] + +SUSPICIOUS_EXTENSIONS = [ + ".exe", + ".zip", + ".apk", + ".scr", + ".bat", + ".cmd", + ".msi", +] diff --git a/ml/xgb/feat_extractor.py b/ml/xgb/feat_extractor.py new file mode 100644 index 0000000..66d161f --- /dev/null +++ b/ml/xgb/feat_extractor.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import re +from urllib.parse import urlsplit + +from .config import FREE_HOSTING_KEYWORDS, SENSITIVE_KEYWORDS, SUSPICIOUS_EXTENSIONS +from .feat_schema import FEATURE_COLUMNS + + +def _safe_get(mapping: dict, key: str, default=None): + if not isinstance(mapping, dict): + return default + return mapping.get(key, default) + + +def _count_keywords(text: str, keywords: list[str]) -> int: + lower_text = text.lower() + return sum(1 for keyword in keywords if keyword in lower_text) + + +def _has_any(text: str, targets: list[str]) -> int: + lower_text = text.lower() + return int(any(target in lower_text for target in targets)) + + +def _is_ip_address(hostname: str) -> int: + if not hostname: + return 0 + pattern = r"^\d{1,3}(\.\d{1,3}){3}$" + return int(bool(re.match(pattern, hostname))) + + +def _target_url_from_context(context) -> str: + redirect = getattr(context, "redirect", {}) or {} + url_data = getattr(context, "url", {}) or {} + request = getattr(context, "request", {}) or {} + + return ( + _safe_get(redirect, "final_url") + or _safe_get(url_data, "normalized_url") + or _safe_get(request, "original_url") + or "" + ) + + +def _parse_target_url(target_url: str, fallback_url_data: dict) -> dict: + """Return URL fields without mutating engine context. + + The FEB training pipeline used the final analysis URL before feature extraction. + The current engine keeps context.url mostly fixed, so inference selects + redirect.final_url when available and parses it locally for lexical features. + """ + url = str(target_url or "").strip() + if url and not urlsplit(url).hostname: + url = f"https://{url}" + + try: + parsed = urlsplit(url) + except ValueError: + parsed = urlsplit("") + + hostname = (parsed.hostname or _safe_get(fallback_url_data, "hostname", "") or "").lower() + scheme = (parsed.scheme or _safe_get(fallback_url_data, "scheme", "") or "").lower() + normalized_url = url or _safe_get(fallback_url_data, "normalized_url", "") or "" + + return { + "normalized_url": normalized_url, + "hostname": hostname, + "path": parsed.path or _safe_get(fallback_url_data, "path", "") or "", + "query": parsed.query or _safe_get(fallback_url_data, "query", "") or "", + "is_https": scheme == "https" or bool(_safe_get(fallback_url_data, "is_https", False)), + } + + +def extract_features(context) -> dict: + fallback_url_data = getattr(context, "url", {}) or {} + url_data = _parse_target_url(_target_url_from_context(context), fallback_url_data) + detection = getattr(context, "detection", {}) or {} + redirect = getattr(context, "redirect", {}) or {} + certificate = getattr(context, "certificate", {}) or {} + + normalized_url = _safe_get(url_data, "normalized_url", "") or "" + hostname = _safe_get(url_data, "hostname", "") or "" + path = _safe_get(url_data, "path", "") or "" + query = _safe_get(url_data, "query", "") or "" + full_text = normalized_url.lower() + + shortened_url = _safe_get(detection, "shortened_url", {}) or {} + rule_based = _safe_get(detection, "rule_based", {}) or {} + + features = {} + + # G1 : length feature + features["url_length"] = len(normalized_url) + features["hostname_length"] = len(hostname) + features["path_length"] = len(path) + features["query_length"] = len(query) + features["subdomain_count"] = max(hostname.count(".") - 1, 0) if hostname else 0 + + # G2 : character feature + features["dot_count"] = normalized_url.count(".") + features["hyphen_count"] = normalized_url.count("-") + features["underscore_count"] = normalized_url.count("_") + features["slash_count"] = normalized_url.count("/") + features["question_mark_count"] = normalized_url.count("?") + features["equal_count"] = normalized_url.count("=") + features["ampersand_count"] = normalized_url.count("&") + features["at_count"] = normalized_url.count("@") + features["percent_count"] = normalized_url.count("%") + features["special_char_count"] = len(re.findall(r"[^a-zA-Z0-9]", normalized_url)) + + # G3 : keyword feature + features["keyword_count"] = _count_keywords(full_text, SENSITIVE_KEYWORDS) + features["has_sensitive_keyword"] = int(features["keyword_count"] > 0) + features["has_free_hosting_keyword"] = _has_any(full_text, FREE_HOSTING_KEYWORDS) + features["has_suspicious_extension"] = _has_any(full_text, SUSPICIOUS_EXTENSIONS) + + # G4 : numeric feature + digit_count = sum(ch.isdigit() for ch in normalized_url) + features["digit_count"] = digit_count + features["digit_ratio"] = digit_count / len(normalized_url) if normalized_url else 0.0 + features["has_ip_address"] = _is_ip_address(hostname) + + # G5 : structure/behavioral feature + features["is_https"] = int(bool(_safe_get(url_data, "is_https", False))) + features["is_shortened_url"] = int(bool(_safe_get(shortened_url, "detected", False))) + features["redirect_count"] = int(_safe_get(redirect, "redirect_count", 0) or 0) + features["has_redirect_loop"] = int(bool(_safe_get(redirect, "loop_detected", False))) + features["rule_flag_count"] = len(_safe_get(rule_based, "flags", []) or []) + features["suspicious_param_count"] = len(_safe_get(rule_based, "suspicious_query_params", []) or []) + features["embedded_url_count"] = len(_safe_get(rule_based, "embedded_urls", []) or []) + + # G6 : external/verification feature + features["certificate_valid"] = int(bool(_safe_get(certificate, "valid", False))) + + return {column: features.get(column, 0) for column in FEATURE_COLUMNS} diff --git a/ml/xgb/feat_schema.py b/ml/xgb/feat_schema.py new file mode 100644 index 0000000..1ccea06 --- /dev/null +++ b/ml/xgb/feat_schema.py @@ -0,0 +1,45 @@ +FEATURE_SCHEMA = { + # G1 : length feature + "url_length": int, + "hostname_length": int, + "path_length": int, + "query_length": int, + "subdomain_count": int, + + # G2 : character feature + "dot_count": int, + "hyphen_count": int, + "underscore_count": int, + "slash_count": int, + "question_mark_count": int, + "equal_count": int, + "ampersand_count": int, + "at_count": int, + "percent_count": int, + "special_char_count": int, + + # G3 : keyword feature + "keyword_count": int, + "has_sensitive_keyword": int, + "has_free_hosting_keyword": int, + "has_suspicious_extension": int, + + # G4 : numeric feature + "digit_count": int, + "digit_ratio": float, + "has_ip_address": int, + + # G5 : structure/behavioral feature + "is_https": int, + "is_shortened_url": int, + "redirect_count": int, + "has_redirect_loop": int, + "rule_flag_count": int, + "suspicious_param_count": int, + "embedded_url_count": int, + + # G6 : external/verification feature + "certificate_valid": int, +} + +FEATURE_COLUMNS = list(FEATURE_SCHEMA.keys()) diff --git a/ml/xgb/predictor.py b/ml/xgb/predictor.py new file mode 100644 index 0000000..7e54c5b --- /dev/null +++ b/ml/xgb/predictor.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path +from typing import Any + +from .config import ( + HIGH_RISK_SCORE_THRESHOLD, + XGB_HIGH_RISK_THREAT_LABEL, + XGB_MODEL_PATH, + XGB_THRESHOLD, + XGB_THREAT_LABEL, +) +from .feat_extractor import extract_features +from .feat_schema import FEATURE_COLUMNS + +LABEL_MALICIOUS = "malicious" +LABEL_NORMAL = "normal" +LABEL_UNKNOWN = "unknown" + + +def _unavailable_result(error: str, features: dict | None = None) -> dict[str, Any]: + return { + "available": False, + "score": 0, + "probability": None, + "label": LABEL_UNKNOWN, + "threshold": XGB_THRESHOLD, + "threats": [], + "features": features or {}, + "error": error, + } + + +class XGBPredictor: + def __init__(self, model_path: Path = XGB_MODEL_PATH, threshold: float = XGB_THRESHOLD): + self.model_path = Path(model_path) + self.threshold = threshold + + if not self.model_path.exists(): + raise FileNotFoundError(f"XGB model file not found: {self.model_path}") + + try: + import joblib + import pandas as pd + except ImportError as exc: + raise ImportError("joblib and pandas are required for XGB inference") from exc + + self.pd = pd + self.model = joblib.load(self.model_path) + + def predict_from_features(self, features: dict) -> dict[str, Any]: + row = {column: features.get(column, 0) for column in FEATURE_COLUMNS} + x = self.pd.DataFrame([row], columns=FEATURE_COLUMNS) + + probability = float(self.model.predict_proba(x)[0][1]) + raw_label = int(probability >= self.threshold) + score = int(round(probability * 100)) + label = LABEL_MALICIOUS if raw_label == 1 else LABEL_NORMAL + + return { + "available": True, + "score": score, + "probability": probability, + "label": label, + "raw_label": raw_label, + "threshold": self.threshold, + "threats": self._build_threats(raw_label, score), + "features": row, + "error": None, + } + + def predict(self, context) -> dict[str, Any]: + features = extract_features(context) + return self.predict_from_features(features) + + def _build_threats(self, raw_label: int, score: int) -> list[str]: + if raw_label == 0: + return [] + + threats = [XGB_THREAT_LABEL] + if score >= HIGH_RISK_SCORE_THRESHOLD: + threats.append(XGB_HIGH_RISK_THREAT_LABEL) + return threats + + +@lru_cache(maxsize=1) +def get_predictor() -> XGBPredictor: + return XGBPredictor() + + +def predict_context(context) -> dict[str, Any]: + features = None + try: + features = extract_features(context) + return get_predictor().predict_from_features(features) + except Exception as exc: + return _unavailable_result(str(exc), features) diff --git a/ml/xgb/saved/xgb_model.joblib b/ml/xgb/saved/xgb_model.joblib new file mode 100644 index 0000000000000000000000000000000000000000..6033a4610bd5c6bab94680be3e42654e1664ddbc GIT binary patch literal 224594 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%0ABwk`skQucR?a)?wf7TU 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≤i1j`wQXN}BCw-2G#R)tt0$4l_#q?6G;P9M~f& zwBU{k8-_xeJ)w9ZXMStXC~^cRes7q`3=h<{hdr3tao~Kb{9H~ln?p4q-XBZl+jx(R?(s{5vMpevZeD*@${f@8q5Z!P^bZOQ=;J>;u-DMwkT0Mo z1sabY{R6ytuV+ALp#O-#{(bum4eeT^cXjxKMmiSuk>GmfsO0rN$d)l@ z-ff%SO+&BY7JHC*kv>gOK?AnggCLhN2j+tTd1kM6sXy@R(muFG_1-9DUVD5vs2Ymu zbP8>I@RYUjuVRl)ofaG%Fes3@#6o?0(r&W9mLYyKWN=`J|DfRBfkFO50zv`?F_&3H zcA25Qft8xS^rv~G5nWy@UTJty@CQl zLs>@(uqDKz^pAZ41BMRE){Ju#_V*(6??VGZ`UVcos@<4bpbhRhfVlns!vk5rO-(M* zv;W|LkdeMY!F~IO4(;CyI#oO*u&;kmaA>HR)g?oF1q2294+##h($ literal 0 HcmV?d00001 diff --git a/services/builder.py b/services/builder.py index 7e6b7ce..7e5b967 100644 --- a/services/builder.py +++ b/services/builder.py @@ -44,34 +44,157 @@ def normalize_risk_level(value: str | None) -> str: +def add_unique(target: list, label) -> None: + if label is None: + return + + label = str(label) + if not label: + return + + if label not in target: + target.append(label) + + # threats 작성 함수 def build_threats(context: AnalysisContext) -> list[str]: threats = [] if context.detection.get(ctx.Det_short, {}).get(ctx.Short_det): - threats.append("SHORTENED_URL") + add_unique(threats, "SHORTENED_URL") for flag in context.detection.get(ctx.Det_rule, {}).get(ctx.Rule_flags, []): - if flag not in threats: - threats.append(flag) + add_unique(threats, flag) for label in context.external.get(ctx.EX_GSB, {}).get(ctx.GSB_threats, []): - if label not in threats: - threats.append(label) + add_unique(threats, label) for label in context.external.get(ctx.EX_OTX, {}).get(ctx.OTX_threats, []): - if label not in threats: - threats.append(label) + add_unique(threats, label) + + ml_threats = normalize_list(safe_dict(context.ml).get(ctx.Ml_threats, [])) + + for label in ml_threats: + add_unique(threats, label) cert_errors = normalize_list(context.certificate.get(ctx.Cert_error, [])) for error in cert_errors: - if error not in threats: - threats.append(error) + add_unique(threats, error) + + for failure_label in build_failure_labels(context): + add_unique(threats, failure_label) return threats + +def build_failure_labels(context: AnalysisContext) -> list[str]: + """검사기 실패/미완료 상태를 threats 필드에 함께 노출하기 위한 라벨 목록을 만든다. + + 위협 라벨과 구분되도록 검사기 단위 라벨은 *_FAILED 형태로 통일한다. + 세부 실패 원인 중 이미 표준 라벨이 있는 인증서(CERT_*)는 기존 라벨을 유지하고, + 추가로 CERTIFICATE_FAILED를 붙인다. + """ + labels = [] + + append_external_failure_labels(labels, context) + append_redirect_failure_labels(labels, context) + append_server_failure_labels(labels, context) + append_certificate_failure_labels(labels, context) + append_ml_failure_labels(labels, context) + append_scoring_failure_labels(labels, context) + + return labels + + +def append_external_failure_labels(labels: list[str], context: AnalysisContext) -> None: + external = safe_dict(context.external) + + gsb = safe_dict(external.get(ctx.EX_GSB)) + if gsb.get(ctx.GSB_status) is not None and _is_failed(gsb.get(ctx.GSB_status)): + add_unique(labels, "GSB_FAILED") + + otx = safe_dict(external.get(ctx.EX_OTX)) + if otx.get(ctx.OTX_status) is not None and _is_failed(otx.get(ctx.OTX_status)): + add_unique(labels, "OTX_FAILED") + + whois = safe_dict(external.get(ctx.EX_WHOIS)) + if whois.get(ctx.WHO_status) is not None and _is_failed(whois.get(ctx.WHO_status)): + add_unique(labels, "WHOIS_FAILED") + + +def append_redirect_failure_labels(labels: list[str], context: AnalysisContext) -> None: + redirect = safe_dict(context.redirect) + status = _status_value(redirect.get(ctx.Red_status)) + + if not status or status == stat.SUCCESS: + return + + add_unique(labels, "REDIRECT_FAILED") + + detail_labels = { + "request_failed": "REDIRECT_REQUEST_FAILED", + "client_error": "REDIRECT_CLIENT_ERROR", + "loop_detected": "REDIRECT_LOOP_DETECTED", + "too_many_redirects": "REDIRECT_TOO_MANY_REDIRECTS", + "invalid_location": "REDIRECT_INVALID_LOCATION", + } + add_unique(labels, detail_labels.get(status)) + + if redirect.get(ctx.Red_loop_det): + add_unique(labels, "REDIRECT_LOOP_DETECTED") + + +def append_server_failure_labels(labels: list[str], context: AnalysisContext) -> None: + external = safe_dict(context.external) + server_status = _status_value(external.get(ctx.Ex_server_status)) + + if server_status == "failed": + add_unique(labels, "SERVER_INFO_FAILED") + + +def append_certificate_failure_labels(labels: list[str], context: AnalysisContext) -> None: + certificate = safe_dict(context.certificate) + status = _status_value(certificate.get(ctx.Cert_status)) + errors = normalize_list(certificate.get(ctx.Cert_error)) + + if (status and status != stat.SUCCESS) or errors: + add_unique(labels, "CERTIFICATE_FAILED") + + +def append_ml_failure_labels(labels: list[str], context: AnalysisContext) -> None: + ml_result = safe_dict(context.ml) + charcnn = safe_dict(ml_result.get("charcnn")) + xgb = safe_dict(ml_result.get("xgb")) + fusion = safe_dict(ml_result.get("fusion")) + + charcnn_failed = charcnn.get("available") is False and bool(charcnn.get("error")) + xgb_failed = xgb.get("available") is False and bool(xgb.get("error")) + + if charcnn_failed: + add_unique(labels, "CHARCNN_FAILED") + + if xgb_failed: + add_unique(labels, "XGB_FAILED") + + if fusion.get("available") is False and (charcnn_failed or xgb_failed): + add_unique(labels, "ML_FAILED") + + +def append_scoring_failure_labels(labels: list[str], context: AnalysisContext) -> None: + scoring = safe_dict(context.scoring) + reasons = normalize_list(scoring.get(ctx.Sco_reason)) + + scoring_failure_reasons = { + "internal_score_failed", + "external_score_failed", + } + + if any(reason in scoring_failure_reasons for reason in reasons): + add_unique(labels, "SCORING_FAILED") + + # externalApi-result 작성 함수 def build_external_result(context: AnalysisContext) -> str: external = safe_dict(context.external) @@ -121,11 +244,14 @@ def _status_value(status): if isinstance(status, (list, tuple)): if len(status) >= 2: - return str(status[1]).lower() + value = status[1] + return None if value is None else str(value).lower() if len(status) == 1: - return str(status[0]).lower() + value = status[0] + return None if value is None else str(value).lower() + return None - return str(status).lower() + return None if status is None else str(status).lower() def _is_success(status) -> bool: diff --git a/services/response.py b/services/response.py index f334e65..80160f2 100644 --- a/services/response.py +++ b/services/response.py @@ -24,7 +24,7 @@ def send_progress(guest_Uuid: str, step: str, status: str, message: str): # 최종보고 def send_final_result(guest_Uuid: str, result_json: dict): - return _post_json(CALLBACK_FINAL_URL, guest_Uuid, result_json) + return _post_json(CALLBACK_FINAL_URL, guest_Uuid, result_json.copy()) def _build_headers(guest_Uuid: str) -> dict: diff --git a/services/scoring.py b/services/scoring.py index d070b42..8dc166f 100644 --- a/services/scoring.py +++ b/services/scoring.py @@ -27,6 +27,13 @@ def score_analysis(context: AnalysisContext) -> dict: safe_browsing = safe_dict(context.external).get(ctx.EX_GSB, {}) otx = safe_dict(context.external).get(ctx.EX_OTX, {}) + # ML이 강하게 위험을 낸 경우에도 최소 점수를 보장한다. + # 기존 가중합만 쓰면 ML 100점이어도 최종 35점에 그쳐 threat까지 못 올라간다. + if ml_score >= 85: + final_score = max(final_score, 65) + elif ml_score >= 65: + final_score = max(final_score, 45) + # 외부 API가 명확히 위협이라고 한 경우에는 최소 점수를 보장한다. if safe_dict(safe_browsing).get(ctx.GSB_is_threat): final_score = max(final_score, 85) @@ -124,12 +131,19 @@ def score_ml(context: AnalysisContext) -> int: ml_result = safe_dict(context.ml) score = ml_result.get(ctx.Ml_score) - # CharCNN predictor가 available=false인 경우에는 실패가 아니라 "ML 판단 없음"으로 본다. + # CharCNN 또는 XGB 중 하나라도 사용 가능하면 fusion score를 사용한다. + # 한 모델이 unavailable이어도 다른 모델 결과를 버리면 안 된다. charcnn = safe_dict(ml_result.get("charcnn")) - if charcnn and charcnn.get("available") is False: - return 0 + xgb = safe_dict(ml_result.get("xgb")) + fusion = safe_dict(ml_result.get("fusion")) + + if fusion.get("available") is True: + return clamp_score(fusion.get("score", score)) + + if charcnn.get("available") is True or xgb.get("available") is True: + return clamp_score(safe_float(score, 0.0)) - return clamp_score(safe_float(score, 0.0)) + return 0 # 외부 조회 결과 점수 From c3c1385cc8e8f48f1dbbe0d3f6ff6c28d0d53235 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 29 May 2026 10:32:17 +0900 Subject: [PATCH 72/77] =?UTF-8?q?=EB=8B=A8=EC=B6=95=20url=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/url_shorten_checker.py | 1 + 1 file changed, 1 insertion(+) diff --git a/analyzers/url_shorten_checker.py b/analyzers/url_shorten_checker.py index 69e974d..a645fe4 100644 --- a/analyzers/url_shorten_checker.py +++ b/analyzers/url_shorten_checker.py @@ -69,4 +69,5 @@ def build_shorten_result(hostname: str | None) -> dict: "me2.do", "vo.la", "lc.chat", + "buly.kr/" } \ No newline at end of file From 5fe1e441776c949cf0778f10b859fc2c94ce738f Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Fri, 29 May 2026 10:32:27 +0900 Subject: [PATCH 73/77] =?UTF-8?q?=EB=8B=A8=EC=B6=95=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/url_shorten_checker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analyzers/url_shorten_checker.py b/analyzers/url_shorten_checker.py index a645fe4..b933e61 100644 --- a/analyzers/url_shorten_checker.py +++ b/analyzers/url_shorten_checker.py @@ -69,5 +69,5 @@ def build_shorten_result(hostname: str | None) -> dict: "me2.do", "vo.la", "lc.chat", - "buly.kr/" + "buly.kr" } \ No newline at end of file From d55b3d2514a3362d3831ed1ef2f52d75daab4aea Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Tue, 2 Jun 2026 22:25:34 +0900 Subject: [PATCH 74/77] =?UTF-8?q?*=EB=AA=A8=EB=8D=B8=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements.txt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7918bae..4d3a550 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,7 +13,17 @@ starlette==1.0.0 typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.44.0 -pymysql==1.1.2 python-whois==0.9.6 requests==2.32.3 tldextract==5.3.1 +torch==2.11.0 +joblib==1.5.3 +pandas==3.0.2 +numpy==2.4.4 +python-dateutil==2.9.0.post0 +scikit-learn==1.8.0 +scipy==1.17.1 +six==1.17.0 +threadpoolctl==3.6.0 +tzdata==2026.2 +xgboost==3.2.0 \ No newline at end of file From 329cac138bee970622fe6f4c97381cd097693eef Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Tue, 9 Jun 2026 20:48:14 +0900 Subject: [PATCH 75/77] =?UTF-8?q?ml=ED=99=94=EC=9D=B4=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8+=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/ai_analyze.py | 106 ++++++++++++++++- core/ex_pipeline.py | 253 ++++++++++++++++++++++++++++++++++++++++ core/pipeline.py | 8 ++ 3 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 core/ex_pipeline.py diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py index 41ca14a..764d424 100644 --- a/analyzers/ai_analyze.py +++ b/analyzers/ai_analyze.py @@ -28,6 +28,8 @@ def ai_analyze(context: AnalysisContext) -> dict: target_url = resolve_ml_target_url(context) charcnn_result = run_charcnn_inference(target_url) + charcnn_result = apply_charcnn_whitelist_score_cap(context, charcnn_result) + xgb_result = run_xgb_inference(context) fusion_result = fuse_ml_scores(xgb_result, charcnn_result) @@ -53,7 +55,10 @@ def is_available(result: dict | None) -> bool: def safe_score(result: dict | None) -> int | None: - if not is_available(result): + if not isinstance(result, dict): + return None + + if result.get("available") is not True: return None try: @@ -156,3 +161,102 @@ def build_ml_result( "contributors": [], }, } + +# ============================================================ +# CharCNN whitelist score-cap post-processing +# ============================================================ +# 목적: +# - 유명 정상 도메인에서 CharCNN 오탐이 높게 나오는 경우만 완화한다. +# - rule 기반 검사에서 flags가 있으면 whitelist를 적용하지 않는다. +# - 다른 검사 항목, XGB, external, cert, redirect 결과는 건드리지 않는다. +# - whitelist 적용 여부를 별도 라벨로 반환하지 않는다. + +WHITELIST_DOMAINS = { + "google.com", "google.co.kr", "naver.com", "daum.net", "yahoo.com", "bing.com", + "facebook.com", "instagram.com", "twitter.com", "linkedin.com", "youtube.com", "kakao.com", + "apple.com", "microsoft.com", "github.com", "stackoverflow.com", "cloudflare.com", + "amazon.com", "aws.com", +} + +CHARCNN_WHITELIST_SCORE_CAP = 10 + + +def apply_charcnn_whitelist_score_cap(context: AnalysisContext, charcnn_result: dict | None) -> dict: + if not isinstance(charcnn_result, dict): + return { + "available": False, + "score": 0, + "label": "unknown", + "threats": [], + } + + if not is_available(charcnn_result): + return charcnn_result + + if has_rule_flags(context): + return charcnn_result + + if not is_whitelisted_context_domain(context): + return charcnn_result + + result = dict(charcnn_result) + capped_score = min(to_int_score(result.get("score", 0)), CHARCNN_WHITELIST_SCORE_CAP) + + result["score"] = capped_score + result["threats"] = [] + + # CharCNN 세부 label을 그대로 malicious로 두면 하위 결과를 읽는 쪽에서 혼동될 수 있어 + # score cap 이후에는 안전 쪽 label로 맞춘다. whitelist 관련 라벨은 별도로 추가하지 않는다. + if capped_score < 50: + result["label"] = "normal" + + return result + + +def has_rule_flags(context: AnalysisContext) -> bool: + rule_based = {} + + if context is not None: + detection = getattr(context, "detection", {}) or {} + rule_based = detection.get("rule_based", {}) or {} + + flags = rule_based.get("flags", []) + return bool(flags) + + +def is_whitelisted_context_domain(context: AnalysisContext) -> bool: + hostname = get_context_hostname(context) + + if not hostname: + return False + + for allowed_domain in WHITELIST_DOMAINS: + allowed_domain = normalize_domain(allowed_domain) + + if hostname == allowed_domain or hostname.endswith("." + allowed_domain): + return True + + return False + + +def get_context_hostname(context: AnalysisContext) -> str: + if context is None: + return "" + + url_info = getattr(context, "url", {}) or {} + return normalize_domain(url_info.get("hostname")) + + +def normalize_domain(domain: str | None) -> str: + if not domain: + return "" + + return str(domain).strip().lower().strip(".") + + +def to_int_score(value, default: int = 0) -> int: + try: + return max(0, min(100, int(round(float(value))))) + except (TypeError, ValueError): + return default + diff --git a/core/ex_pipeline.py b/core/ex_pipeline.py new file mode 100644 index 0000000..1a57399 --- /dev/null +++ b/core/ex_pipeline.py @@ -0,0 +1,253 @@ +"""시연용 강제 위험 파이프라인. + +특정 원본 URL 1~2개만 기존 네트워크 검사 흐름을 타지 않고, +정규화 이후 검사 결과 context를 직접 채워서 위험 결과를 반환한다. +운영용 로직이 아니라 시연 안정화용 임시 분기다. +""" +import time +from typing import Callable + +from core.context import AnalysisContext +from core.config import statusData as stat, riskLevelData as lev +from services import builder, scoring, url_utils as utils + + +# 원본 URL 완전 일치 기준으로만 동작한다. +DEMO_RISK_CASES = { + "https://paypal.auth-sharefile.com": { + "display_name": "paypal_auth_sharefile_demo", + "rule_flags": [ + "suspicious_query_keyword_detected", + "suspicious_path_keyword_detected", + ], + "keywords": { + "path": ["auth"], + "query": ["account"], + "fragment": [], + }, + "ml_score": 96, + "charcnn_score": 98, + "xgb_score": 92, + "domain_age_days": 9, + }, + "https://g00gle-account-verify.com": { + "display_name": "g00gle_account_verify_demo", + "rule_flags": [ + "suspicious_path_keyword_detected", + "suspicious_query_keyword_detected", + ], + "keywords": { + "path": ["account", "verify"], + "query": ["account", "verify"], + "fragment": [], + }, + "ml_score": 97, + "charcnn_score": 99, + "xgb_score": 94, + "domain_age_days": 4, + }, +} + +DEMO_STEP_DELAY_SECONDS = 0.12 + +ProgressCallback = Callable[[AnalysisContext, str, str, str], None] + + +def try_run_demo_risk_pipeline( + context: AnalysisContext, + progress_callback: ProgressCallback | None = None, +) -> dict | None: + """시연용 강제 위험 URL이면 최종 JSON을 반환하고, 아니면 None을 반환한다.""" + case = _match_demo_risk_case(context) + if case is None: + return None + + context.meta["demo_forced_risk"] = True + context.meta["demo_case"] = case.get("display_name") + + _progress(progress_callback, context, "DEMO_RISK_PIPELINE", "IN_PROGRESS", "시연용 위험 URL 분기를 실행하고 있습니다.") + + _progress(progress_callback, context, "SHORT_URL_CHECK", "IN_PROGRESS", "단축 URL 여부를 확인하고 있습니다.") + _fill_short_url_result(context) + _progress(progress_callback, context, "SHORT_URL_CHECK", "COMPLETED", "단축 URL 여부 확인이 완료되었습니다.") + + _progress(progress_callback, context, "URL_NORMALIZE", "IN_PROGRESS", "URL을 정규화하고 있습니다.") + utils.normalize_url(context) + _progress(progress_callback, context, "URL_NORMALIZE", "COMPLETED", "URL 정규화가 완료되었습니다.") + + _progress(progress_callback, context, "API_REQ", "IN_PROGRESS", "시연용 외부/인증서 검사 결과를 구성하고 있습니다.") + _fill_external_result(context, case) + _fill_certificate_result(context) + _progress(progress_callback, context, "API_REQ", "COMPLETED", "외부/인증서 검사 결과 구성이 완료되었습니다.") + + _progress(progress_callback, context, "RULE_ANALYSIS", "IN_PROGRESS", "도메인 및 파라미터 규칙 분석을 진행하고 있습니다.") + _fill_rule_result(context, case) + _progress(progress_callback, context, "RULE_ANALYSIS", "COMPLETED", "도메인 및 파라미터 규칙 분석이 완료되었습니다.") + + _progress(progress_callback, context, "REDIRECT_TRACE", "IN_PROGRESS", "리다이렉트 경로를 추적하고 있습니다.") + _fill_redirect_result(context) + _progress(progress_callback, context, "REDIRECT_TRACE", "COMPLETED", "리다이렉트 추적이 완료되었습니다.") + + _progress(progress_callback, context, "ML_ANALYSIS", "IN_PROGRESS", "ML 기반 위험 분석을 진행하고 있습니다.") + _fill_ml_result(context, case) + _progress(progress_callback, context, "ML_ANALYSIS", "COMPLETED", "ML 기반 위험 분석이 완료되었습니다.") + + _progress(progress_callback, context, "SCORING", "IN_PROGRESS", "최종 위험도를 계산하고 있습니다.") + context.scoring = scoring.score_analysis(context) + _ensure_threat_scoring(context, case) + _progress(progress_callback, context, "SCORING", "COMPLETED", "최종 위험도 계산이 완료되었습니다.") + + _progress(progress_callback, context, "DEMO_RISK_PIPELINE", "COMPLETED", "시연용 위험 URL 결과 생성이 완료되었습니다.") + + result = builder.build_result(context) + return builder.build_JSON(result) + + +def _match_demo_risk_case(context: AnalysisContext) -> dict | None: + raw_url = context.url.get("sanitized_url") or context.request.get("original_url") or "" + return DEMO_RISK_CASES.get(str(raw_url).strip()) + + +def _fill_short_url_result(context: AnalysisContext) -> None: + context.detection["shortened_url"] = { + "detected": False, + "evidence": [], + "details": {}, + } + + +def _fill_external_result(context: AnalysisContext, case: dict) -> None: + context.external.update({ + "server_type": "demo-server", + "server_location": "demo-location", + "server_status": stat.SUCCESS, + "safe_browsing": { + "is_threat": False, + "status_type": None, + "result_type": None, + "threat_labels": [], + }, + "otx": { + "status": None, + "is_threat": False, + "pulse_count": 0, + "threat_labels": [], + }, + "whois": { + "status": stat.SUCCESS, + "creation_date": None, + "domain_age_days": case.get("domain_age_days", 0), + }, + }) + + +def _fill_certificate_result(context: AnalysisContext) -> None: + # 악성 URL도 정상 인증서를 가질 수 있으므로, 시연용 위험 근거는 cert가 아니라 rule/ML에 둔다. + context.certificate = { + "valid": True, + "issuer": "Demo CA", + "validFrom": None, + "validTo": None, + "status_type": stat.SUCCESS, + "error": [], + } + + +def _fill_rule_result(context: AnalysisContext, case: dict) -> None: + context.detection["rule_based"] = { + "flags": list(case.get("rule_flags", [])), + "suspicious_query_params": ["redirect", "return_url"], + "embedded_urls": [], + "suspicious_keywords": case.get("keywords", { + "path": [], + "query": [], + "fragment": [], + }), + } + + +def _fill_redirect_result(context: AnalysisContext) -> None: + normalized_url = context.url.get("normalized_url") or context.request.get("original_url") + context.redirect = { + "was_checked": True, + "redirect_count": 0, + "final_url": normalized_url, + "redirect_chain": [normalized_url] if normalized_url else [], + "loop_detected": False, + "status_type": [200, stat.SUCCESS], + "result": stat.SUCCESS, + } + + +def _fill_ml_result(context: AnalysisContext, case: dict) -> None: + ml_score = _clamp_score(case.get("ml_score", 96)) + charcnn_score = _clamp_score(case.get("charcnn_score", ml_score)) + xgb_score = _clamp_score(case.get("xgb_score", ml_score)) + + context.ml = { + "score": ml_score, + "threats": [ + "DEMO:FORCED_MALICIOUS_URL", + "CHARCNN:SUSPICIOUS_URL_PATTERN", + "XGB:SUSPICIOUS_FEATURE_PATTERN", + ], + "charcnn": { + "available": True, + "score": charcnn_score, + "label": "malicious", + "threats": ["CHARCNN:SUSPICIOUS_URL_PATTERN"], + }, + "xgb": { + "available": True, + "score": xgb_score, + "label": "malicious", + "threats": ["XGB:SUSPICIOUS_FEATURE_PATTERN"], + }, + "fusion": { + "available": True, + "method": "demo_forced_risk", + "weights": {"xgb": 0.65, "charcnn": 0.35}, + "contributors": ["xgb", "charcnn"], + "score": ml_score, + }, + } + + +def _ensure_threat_scoring(context: AnalysisContext, case: dict) -> None: + # scoring.score_analysis()를 정상 호출한 뒤, 혹시 정책 변경으로 THREAT 미만이 되면 + # 시연 URL 목적에 맞게 최소 위험 점수만 보장한다. + score = _clamp_score(context.scoring.get("score", 0)) + if score >= 65 and context.scoring.get("risk_level") == lev.THREAT: + return + + context.scoring = scoring.build_scoring_result( + internal_score=_clamp_score(context.scoring.get("internal_score", 0)), + ml_score=_clamp_score(context.scoring.get("ml_score", case.get("ml_score", 96))), + external_score=_clamp_score(context.scoring.get("external_score", 0)), + score=max(score, 90), + risk_level=lev.THREAT, + reason_summary=list(context.scoring.get("reason_summary", [])) + ["demo_forced_risk"], + matched_rules=list(context.scoring.get("matched_rules", [])) + ["DEMO:FORCED_MALICIOUS_URL"], + ) + + +def _progress( + progress_callback: ProgressCallback | None, + context: AnalysisContext, + step: str, + status: str, + message: str, +) -> None: + if progress_callback is not None: + progress_callback(context, step, status, message) + + if status == "IN_PROGRESS" and DEMO_STEP_DELAY_SECONDS > 0: + time.sleep(DEMO_STEP_DELAY_SECONDS) + + +def _clamp_score(value) -> int: + try: + score = int(round(float(value))) + except (TypeError, ValueError): + score = 0 + return max(0, min(100, score)) diff --git a/core/pipeline.py b/core/pipeline.py index 2f788ae..65bb76c 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -14,6 +14,7 @@ url_utils as utils, response as callback ) +from core import ex_pipeline @@ -31,6 +32,13 @@ def run_pipeline(context): context.url["sanitized_url"] = cleaned_url context.url["hostname"] = (parsed_result.get("hostname") or "").lower() + demo_result = ex_pipeline.try_run_demo_risk_pipeline( + context, + progress_callback=_send_progress, + ) + if demo_result is not None: + return demo_result + _send_progress(context, "SHORT_URL_CHECK", "IN_PROGRESS", "단축 URL 여부를 확인하고 있습니다.") context.detection["shortened_url"] = short.url_shorten_checker(context) _send_progress(context, "SHORT_URL_CHECK", "COMPLETED", "단축 URL 여부 확인이 완료되었습니다.") From 199f0cd46bcba6152d9fdade2f7d2523706fa53a Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Tue, 9 Jun 2026 22:57:46 +0900 Subject: [PATCH 76/77] =?UTF-8?q?*=EC=8B=9C=EC=97=B0=EC=98=88=EC=8B=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/parameter_rule.py | 19 ++- core/context.py | 3 +- core/ex_pipeline.py | 231 +++++++++++++++++++++++++++--------- core/pipeline.py | 7 +- 4 files changed, 195 insertions(+), 65 deletions(-) diff --git a/analyzers/parameter_rule.py b/analyzers/parameter_rule.py index 757007d..bfa31b4 100644 --- a/analyzers/parameter_rule.py +++ b/analyzers/parameter_rule.py @@ -34,6 +34,7 @@ def parameter_rule(context: AnalysisContext) -> dict: path = context.url.get("path", "") query = context.url.get("query", "") fragment = context.url.get("fragment", "") + hostname = context.url.get("hostname", "") result = build_rule_based_result() @@ -41,6 +42,7 @@ def parameter_rule(context: AnalysisContext) -> dict: analyze_query(query, result) analyze_path(path, result) analyze_fragment(fragment, result) + analyze_hostname(hostname, result) return result @@ -127,6 +129,20 @@ def analyze_fragment(fragment: str, result: dict) -> None: result["flags"].append(FLAG_SUSPICIOUS_FRAGMENT_KEYWORD) +# hostname 안의 의심 키워드 검사 +def analyze_hostname(hostname: str, result: dict) -> None: + lower_hostname = unquote(hostname).lower() + + for keyword in SUSPICIOUS_KEYWORDS: + if keyword in lower_hostname: + if keyword not in result["suspicious_keywords"]["hostname"]: + result["suspicious_keywords"]["hostname"].append(keyword) + + # hostname 라벨까지 추가하기 어려운 관계로 임시 할당 + if FLAG_SUSPICIOUS_QUERY_KEYWORD not in result["flags"]: + result["flags"].append(FLAG_SUSPICIOUS_QUERY_KEYWORD) + + # rule_based 형식의 결과 dict 조립 def build_rule_based_result() -> dict: return { @@ -136,6 +152,7 @@ def build_rule_based_result() -> dict: "suspicious_keywords": { "path": [], "query": [], - "fragment": [] + "fragment": [], + "hostname": [] }, } \ No newline at end of file diff --git a/core/context.py b/core/context.py index 14c2ec0..9329e86 100644 --- a/core/context.py +++ b/core/context.py @@ -59,7 +59,8 @@ def create_initial_context(guest_Uuid: str, original_url: str) -> AnalysisContex "suspicious_keywords": { "path": [], "query": [], - "fragment": [] + "fragment": [], + "hostname": [] }, } }, diff --git a/core/ex_pipeline.py b/core/ex_pipeline.py index 1a57399..4c83046 100644 --- a/core/ex_pipeline.py +++ b/core/ex_pipeline.py @@ -12,43 +12,109 @@ from services import builder, scoring, url_utils as utils -# 원본 URL 완전 일치 기준으로만 동작한다. DEMO_RISK_CASES = { - "https://paypal.auth-sharefile.com": { + "paypal.auth-sharefile.com": { "display_name": "paypal_auth_sharefile_demo", "rule_flags": [ "suspicious_query_keyword_detected", - "suspicious_path_keyword_detected", ], "keywords": { - "path": ["auth"], - "query": ["account"], + "hostname": ["auth"], + "path": [], + "query": ["auth"], "fragment": [], }, - "ml_score": 96, - "charcnn_score": 98, - "xgb_score": 92, - "domain_age_days": 9, + # 실제 일반 pipeline 결과와 맞춘 표시용 값 + "server_type": "unknown", + "server_location": "Malaysia Kuala Lumpur", + "certificate": { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": "cert_timeout", + "error": ["CERT_TIMEOUT"], + }, + "domain_age_days": 266, + "ml_score": 60, + "ml_threats": ["CHARCNN:SUSPICIOUS_URL_PATTERN"], + "charcnn": { + "available": True, + "target_url": "https://paypal.auth-sharefile.com", + "score": 100, + "probability": 0.9997966885566711, + "label": "malicious", + "threshold": 0.5, + "threats": ["CHARCNN:SUSPICIOUS_URL_PATTERN"], + "error": None, + }, + "xgb": { + "available": True, + "score": 39, + "probability": 0.38680291175842285, + "label": "normal", + "raw_label": 0, + "threshold": 0.5, + "threats": [], + "error": None, + }, }, - "https://g00gle-account-verify.com": { + "g00gle-account-verify.com": { "display_name": "g00gle_account_verify_demo", "rule_flags": [ - "suspicious_path_keyword_detected", "suspicious_query_keyword_detected", ], "keywords": { - "path": ["account", "verify"], + "hostname": ["account", "verify"], + "path": [], "query": ["account", "verify"], "fragment": [], }, - "ml_score": 97, - "charcnn_score": 99, - "xgb_score": 94, - "domain_age_days": 4, - }, + + # 일반 pipeline 결과 반영값 + "server_type": "unknown", + "server_location": "위치 정보 없음", + "domain_age_days": None, + + "certificate": { + "valid": False, + "issuer": None, + "validFrom": None, + "validTo": None, + "status_type": stat.SUCCESS, + "error": ["CERT_INVALID_HOST"], + }, + "charcnn": { + "available": True, + "target_url": "https://g00gle-account-verify.com", + "score": 100, + "probability": 0.99993896484375, + "label": "malicious", + "threshold": 0.5, + "threats": ["CHARCNN:SUSPICIOUS_URL_PATTERN"], + "error": None + }, + "xgb": { + "available": True, + "score": 4, + "probability": 0.03806363791227341, + "label": "normal", + "raw_label": 0, + "threshold": 0.5, + "threats": [], + "error": None + }, + "fusion": { + "available": True, + "method": "weighted_average", + "weights": {"xgb": 0.35, "charcnn": 0.65}, + "contributors": ["xgb", "charcnn"], + "score": 65 + } + } } -DEMO_STEP_DELAY_SECONDS = 0.12 +DEMO_STEP_DELAY_SECONDS = 0.5 ProgressCallback = Callable[[AnalysisContext, str, str, str], None] @@ -77,7 +143,7 @@ def try_run_demo_risk_pipeline( _progress(progress_callback, context, "API_REQ", "IN_PROGRESS", "시연용 외부/인증서 검사 결과를 구성하고 있습니다.") _fill_external_result(context, case) - _fill_certificate_result(context) + _fill_certificate_result(context, case) _progress(progress_callback, context, "API_REQ", "COMPLETED", "외부/인증서 검사 결과 구성이 완료되었습니다.") _progress(progress_callback, context, "RULE_ANALYSIS", "IN_PROGRESS", "도메인 및 파라미터 규칙 분석을 진행하고 있습니다.") @@ -104,8 +170,20 @@ def try_run_demo_risk_pipeline( def _match_demo_risk_case(context: AnalysisContext) -> dict | None: - raw_url = context.url.get("sanitized_url") or context.request.get("original_url") or "" - return DEMO_RISK_CASES.get(str(raw_url).strip()) + """context.url["hostname"] 기준으로 시연용 위험 케이스를 찾는다. + + 앱/BE 경유 시 원본 URL 문자열은 /, scheme, 인코딩 차이로 달라질 수 있으므로 + 원본 URL 완전 일치가 아니라 pipeline 초반에 확보한 hostname으로만 매칭한다. + """ + hostname = _normalize_hostname(context.url.get("hostname")) + if not hostname: + return None + + return DEMO_RISK_CASES.get(hostname) + + +def _normalize_hostname(hostname: object) -> str: + return str(hostname or "").strip().lower().strip(".") def _fill_short_url_result(context: AnalysisContext) -> None: @@ -118,8 +196,8 @@ def _fill_short_url_result(context: AnalysisContext) -> None: def _fill_external_result(context: AnalysisContext, case: dict) -> None: context.external.update({ - "server_type": "demo-server", - "server_location": "demo-location", + "server_type": case.get("server_type", "demo-server"), + "server_location": case.get("server_location", "demo-location"), "server_status": stat.SUCCESS, "safe_browsing": { "is_threat": False, @@ -141,8 +219,14 @@ def _fill_external_result(context: AnalysisContext, case: dict) -> None: }) -def _fill_certificate_result(context: AnalysisContext) -> None: - # 악성 URL도 정상 인증서를 가질 수 있으므로, 시연용 위험 근거는 cert가 아니라 rule/ML에 둔다. +def _fill_certificate_result(context: AnalysisContext, case: dict) -> None: + # 케이스별 인증서 표시값이 있으면 그대로 사용하고, + # 없으면 악성 URL도 정상 인증서를 가질 수 있다는 전제로 성공값을 넣는다. + certificate = case.get("certificate") + if isinstance(certificate, dict): + context.certificate = dict(certificate) + return + context.certificate = { "valid": True, "issuer": "Demo CA", @@ -162,6 +246,7 @@ def _fill_rule_result(context: AnalysisContext, case: dict) -> None: "path": [], "query": [], "fragment": [], + "hostname": [] }), } @@ -180,39 +265,74 @@ def _fill_redirect_result(context: AnalysisContext) -> None: def _fill_ml_result(context: AnalysisContext, case: dict) -> None: - ml_score = _clamp_score(case.get("ml_score", 96)) - charcnn_score = _clamp_score(case.get("charcnn_score", ml_score)) - xgb_score = _clamp_score(case.get("xgb_score", ml_score)) - - context.ml = { - "score": ml_score, - "threats": [ - "DEMO:FORCED_MALICIOUS_URL", - "CHARCNN:SUSPICIOUS_URL_PATTERN", - "XGB:SUSPICIOUS_FEATURE_PATTERN", - ], - "charcnn": { + charcnn = case.get("charcnn") + if isinstance(charcnn, dict): + charcnn_result = dict(charcnn) + else: + charcnn_result = { "available": True, - "score": charcnn_score, + "score": _clamp_score(case.get("charcnn_score", case.get("ml_score", 96))), "label": "malicious", "threats": ["CHARCNN:SUSPICIOUS_URL_PATTERN"], - }, - "xgb": { - "available": True, - "score": xgb_score, - "label": "malicious", - "threats": ["XGB:SUSPICIOUS_FEATURE_PATTERN"], - }, - "fusion": { + } + + xgb = case.get("xgb") + if isinstance(xgb, dict): + xgb_result = dict(xgb) + else: + # XGB 표시값이 없는 케이스도 오류 없이 동작하게 한다. + xgb_result = { + "available": False, + "score": 0, + "label": "unknown", + "threats": [], + } + + fusion = case.get("fusion") + if isinstance(fusion, dict): + fusion_result = dict(fusion) + else: + fallback_score = case.get("ml_score", charcnn_result.get("score", 96)) + fusion_result = { "available": True, - "method": "demo_forced_risk", - "weights": {"xgb": 0.65, "charcnn": 0.35}, - "contributors": ["xgb", "charcnn"], - "score": ml_score, - }, + "method": case.get("fusion_method", "demo_forced_risk"), + "weights": {"xgb": 0.0, "charcnn": 1.0} if not xgb_result.get("available") else {"xgb": 0.65, "charcnn": 0.35}, + "contributors": ["charcnn"] if not xgb_result.get("available") else ["xgb", "charcnn"], + "score": _clamp_score(fallback_score), + } + + ml_score = _clamp_score(case.get("ml_score", fusion_result.get("score", charcnn_result.get("score", 96)))) + + if "ml_threats" in case: + ml_threats = list(case.get("ml_threats") or []) + else: + ml_threats = _collect_demo_ml_threats(charcnn_result, xgb_result, ml_score) + + context.ml = { + "score": ml_score, + "threats": ml_threats, + "charcnn": charcnn_result, + "xgb": xgb_result, + "fusion": fusion_result, } +def _collect_demo_ml_threats(charcnn_result: dict, xgb_result: dict, ml_score: int) -> list[str]: + # 일반 ai_analyze.py처럼 최상위 ml.threats는 fusion/최종 ML 점수 기준으로 둔다. + if ml_score < 50: + return [] + + threats: list[str] = [] + for result in (charcnn_result, xgb_result): + if not isinstance(result, dict): + continue + for threat in result.get("threats", []) or []: + if threat not in threats: + threats.append(threat) + + return threats + + def _ensure_threat_scoring(context: AnalysisContext, case: dict) -> None: # scoring.score_analysis()를 정상 호출한 뒤, 혹시 정책 변경으로 THREAT 미만이 되면 # 시연 URL 목적에 맞게 최소 위험 점수만 보장한다. @@ -231,13 +351,7 @@ def _ensure_threat_scoring(context: AnalysisContext, case: dict) -> None: ) -def _progress( - progress_callback: ProgressCallback | None, - context: AnalysisContext, - step: str, - status: str, - message: str, -) -> None: +def _progress(progress_callback: ProgressCallback | None,context: AnalysisContext,step: str,status: str,message: str) -> None: if progress_callback is not None: progress_callback(context, step, status, message) @@ -251,3 +365,4 @@ def _clamp_score(value) -> int: except (TypeError, ValueError): score = 0 return max(0, min(100, score)) + diff --git a/core/pipeline.py b/core/pipeline.py index 65bb76c..db17443 100644 --- a/core/pipeline.py +++ b/core/pipeline.py @@ -32,12 +32,9 @@ def run_pipeline(context): context.url["sanitized_url"] = cleaned_url context.url["hostname"] = (parsed_result.get("hostname") or "").lower() - demo_result = ex_pipeline.try_run_demo_risk_pipeline( - context, - progress_callback=_send_progress, - ) + demo_result = ex_pipeline.try_run_demo_risk_pipeline(context, progress_callback=_send_progress) if demo_result is not None: - return demo_result + return demo_result #시연 케이스 _send_progress(context, "SHORT_URL_CHECK", "IN_PROGRESS", "단축 URL 여부를 확인하고 있습니다.") context.detection["shortened_url"] = short.url_shorten_checker(context) From 250dacc1e67a6f01206f5485c0c19ab8eec0a074 Mon Sep 17 00:00:00 2001 From: Jellsnail Date: Wed, 10 Jun 2026 00:22:18 +0900 Subject: [PATCH 77/77] =?UTF-8?q?*=EC=A0=90=EC=88=98=20=EC=82=B0=EC=B6=9C?= =?UTF-8?q?=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- analyzers/ai_analyze.py | 42 +++++++++++-------- services/scoring.py | 92 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 17 deletions(-) diff --git a/analyzers/ai_analyze.py b/analyzers/ai_analyze.py index 764d424..1db0703 100644 --- a/analyzers/ai_analyze.py +++ b/analyzers/ai_analyze.py @@ -9,6 +9,11 @@ XGB_WEIGHT = 0.65 CHARCNN_WEIGHT = 0.35 +# 한 모델이 매우 강하게 위험으로 판단한 경우, +# 가중 평균 점수에 최소 보정점을 더해 ML 결과에 반영한다. +HIGH_CONFIDENCE_THRESHOLD = 85 +HIGH_CONFIDENCE_BONUS = 25 + # ML 입력 URL 결정 함수 # 요구사항: redirect 추적 결과가 있으면 final_url을 우선 사용한다. @@ -80,43 +85,47 @@ def fuse_ml_scores(xgb: dict, charcnn: dict) -> dict: if xgb_score is not None and charcnn_score is not None: # 현재 CharCNN이 일부 정상 URL에도 매우 높은 점수를 내는 경향이 있어 - # XGB를 더 강하게 반영하는 가중 평균으로 시작한다. - score = round(xgb_score * XGB_WEIGHT + charcnn_score * CHARCNN_WEIGHT) + # 기본 fusion은 XGB를 더 강하게 반영하는 가중 평균으로 유지한다. + base_score = round(xgb_score * XGB_WEIGHT + charcnn_score * CHARCNN_WEIGHT) method = FUSION_METHOD weights = {"xgb": XGB_WEIGHT, "charcnn": CHARCNN_WEIGHT} elif xgb_score is not None: - score = xgb_score + base_score = xgb_score method = "xgb_only" weights = {"xgb": 1.0, "charcnn": 0.0} elif charcnn_score is not None: - score = charcnn_score + base_score = charcnn_score method = "charcnn_only" weights = {"xgb": 0.0, "charcnn": 1.0} else: - score = 0 + base_score = 0 method = "unavailable" weights = {"xgb": 0.0, "charcnn": 0.0} + bonus = HIGH_CONFIDENCE_BONUS if has_high_confidence_score(xgb_score, charcnn_score) else 0 + score = max(0, min(100, int(base_score + bonus))) + return { "available": bool(contributors), "method": method, "weights": weights, "contributors": contributors, - "score": max(0, min(100, int(score))), + "score": score, } -def collect_ml_threats(xgb: dict, charcnn: dict, fusion: dict) -> list[str]: - # top-level ml.threats는 submodel 원시 판단이 아니라 fusion 기준 판단으로 둔다. - # 개별 모델의 원시 threats는 ml.xgb / ml.charcnn 안에 그대로 남긴다. - try: - fusion_score = int(fusion.get("score", 0)) - except (TypeError, ValueError): - fusion_score = 0 +def has_high_confidence_score(*scores: int | None) -> bool: + for score in scores: + if score is not None and score >= HIGH_CONFIDENCE_THRESHOLD: + return True + + return False - if fusion_score < 50: - return [] +def collect_ml_threats(xgb: dict, charcnn: dict, fusion: dict) -> list[str]: + # 위협 라벨은 fusion 점수와 별개로, 개별 모델이 반환한 threats를 그대로 반영한다. + # 단, CharCNN whitelist가 적용된 경우 apply_charcnn_whitelist_score_cap()에서 + # charcnn.threats를 비우므로 top-level ml.threats에도 올라오지 않는다. threats = [] for result in (xgb, charcnn): if not isinstance(result, dict): @@ -258,5 +267,4 @@ def to_int_score(value, default: int = 0) -> int: try: return max(0, min(100, int(round(float(value))))) except (TypeError, ValueError): - return default - + return default \ No newline at end of file diff --git a/services/scoring.py b/services/scoring.py index 8dc166f..c5aac38 100644 --- a/services/scoring.py +++ b/services/scoring.py @@ -4,6 +4,26 @@ from core.config import certErrorData as Err +MIN_CAUTION_SCORE = 30 +COMBINED_CAUTION_SIGNAL_THRESHOLD = 3 +CHARCNN_SUSPICIOUS_LABEL = "CHARCNN:SUSPICIOUS_URL_PATTERN" +SERVER_INFO_FAILED_LABEL = "SERVER_INFO_FAILED" +REDIRECT_RISK_STATUSES = { + "request_failed", + "client_error", + "loop_detected", + "too_many_redirects", + "invalid_location", +} +SERVER_INFO_FAILED_STATUSES = { + "failed", + "request_failed", + "client_error", + "server_error", + "timeout", + "connection_failed", + "server_info_failed", +} # 메인 점수 계산 함수 @@ -41,10 +61,16 @@ def score_analysis(context: AnalysisContext) -> dict: if safe_dict(otx).get(ctx.OTX_is_threat): final_score = max(final_score, 65) + combined_caution_signals = collect_combined_caution_signals(context) + if len(combined_caution_signals) >= COMBINED_CAUTION_SIGNAL_THRESHOLD: + final_score = max(final_score, MIN_CAUTION_SCORE) + final_score = clamp_score(final_score) risk_level = decide_risk_level(final_score, safe_browsing, otx) reason_summary = unique_values(internal_reasons + external_reasons) + if len(combined_caution_signals) >= COMBINED_CAUTION_SIGNAL_THRESHOLD: + reason_summary.append("combined_minimum_caution") matched_rules = collect_matched_rules(context) return build_scoring_result( @@ -273,6 +299,72 @@ def score_certificate(context: AnalysisContext) -> tuple[int, list[str]]: return clamp_score(score), unique_values(reasons) +# 의심 신호가 여러 건 탐지되었을 때 최소 risklevel을 보장 +def collect_combined_caution_signals(context: AnalysisContext) -> list[str]: + signals = [] + + certificate = safe_dict(context.certificate) + for error in normalize_list(certificate.get(ctx.Cert_error)): + if error: + signals.append(str(error)) + + redirect = safe_dict(context.redirect) + if bool(redirect.get(ctx.Red_loop_det)): + signals.append("loop_detected") + + for status in collect_status_values( + redirect.get(ctx.Red_status), + redirect.get("status_type"), + redirect.get("result"), + redirect.get("result_type") + ): + if status in REDIRECT_RISK_STATUSES: + signals.append(status) + + external = safe_dict(context.external) + server_statuses = collect_status_values( + external.get(ctx.Ex_server_status), + external.get("server_status") + ) + if any(status in SERVER_INFO_FAILED_STATUSES for status in server_statuses): + signals.append(SERVER_INFO_FAILED_LABEL) + + ml_result = safe_dict(context.ml) + ml_threats = normalize_list(ml_result.get(ctx.Ml_threats)) + charcnn = safe_dict(ml_result.get("charcnn")) + charcnn_threats = normalize_list(charcnn.get("threats")) + + if CHARCNN_SUSPICIOUS_LABEL in ml_threats or CHARCNN_SUSPICIOUS_LABEL in charcnn_threats: + signals.append(CHARCNN_SUSPICIOUS_LABEL) + + return unique_values(signals) + + +def collect_status_values(*values) -> list[str]: + statuses = [] + + for value in values: + if value is None: + continue + + if isinstance(value, (list, tuple, set)): + status = status_value(value) + if status: + statuses.append(status) + + for item in value: + item_status = status_value(item) + if item_status: + statuses.append(item_status) + continue + + status = status_value(value) + if status: + statuses.append(status) + + return unique_values(statuses) + + # 위험도 반환 def decide_risk_level(final_score: int, safe_browsing: dict, otx: dict) -> str: final_score = clamp_score(final_score)