diff --git a/README.md b/README.md index 96201f9..c4ae946 100644 --- a/README.md +++ b/README.md @@ -1,787 +1,269 @@ -# LinClean-BE-fastapi (URL 보안 엔진) +# LinClean FastAPI -**LinClean의 URL 검역 백엔드입니다.**
-카톡·문자·메일로 받은 링크를 열기 전에 4단계 파이프라인으로 검사해 -**안전 / 주의 / 위험** 판정과 그 근거를 산출하는 **상태 비저장(stateless) 분석 전용 서버** 입니다. +## 핵심 목표 ---- +LinClean FastAPI는 Spring 서비스가 전달한 URL을 열기 전에 분석해 `safe`, `caution`, `danger` verdict와 근거를 반환하는 URL 보안 엔진입니다. + +주요 목표는 다음과 같습니다. + +- 단축 URL과 리다이렉트를 풀어 실제 도착지를 확인합니다. +- Google Safe Browsing, URLhaus, 도메인 휴리스틱, 콘텐츠 분석, AI 보조 판정을 한 파이프라인에서 합산합니다. +- 사라진 페이지나 400번대 페이지는 무리하게 verdict를 만들지 않고 실패 상태로 Spring에 콜백합니다. +- `caution` 이상 판정에서는 AI 분석 근거와 사용자 행동 가이드를 기존 `ai_reason` 안에 100자 이내로 담습니다. ## 기술 스택 -| 분류 | 기술 | 비고 | -|------|-------------------------------------------------|------| -| Framework | **FastAPI** | lifespan, async, app factory | -| Language | **Python 3.11+** | 타입 힌트, async/await | -| 로컬 캐시 DB | **SQLite + aiosqlite** | URLhaus 등 외부 피드 캐시 전용 | -| ORM | **SQLAlchemy 2.0 (async)** | DeclarativeBase + naming convention | -| Migration | **Alembic** | SQLite batch mode | -| HTTP Client | **httpx** | 외부 API 호출 (GSB / RDAP / OpenAI / Spring 콜백) | -| Crawler | **BeautifulSoup4 + requests** | 페이지 본문 추출, 피싱 신호 탐지 | -| Domain Lookup | **RDAP (httpx)** | 도메인 등록일·만료일·레지스트라 조회 | -| 캐시 | **인메모리 dict + TTL + single-flight** / **SQLite 스냅샷** / **`functools.lru_cache`** | RDAP 7일 캐시·동시 요청 합치기 / URLhaus 로컬 캐시 / Settings 싱글톤 | -| Scheduler | **APScheduler** | URLhaus 주기 동기화 | -| Validation | **Pydantic v2 + pydantic-settings** | 요청·응답·환경변수 | -| Logging | **structlog** | 구조적 로깅 + request_id 자동 바인딩 | -| Lint / Format | **Ruff** | E/F/I/B/SIM/S/UP 룰셋 | -| Type Check | **mypy (strict)** | pydantic 플러그인 | -| Test | **pytest + pytest-asyncio + httpx AsyncClient** | ASGI 테스트 | -| Packaging | **hatchling** | PEP 621 | -| AI | **OpenAI Chat Completions (gpt-4o-mini 기본)** | 페이지 콘텐츠 정적 분석 보조 — 모델은 `OPENAI_MODEL` 로 교체 | - ---- +| 구분 | 기술 | +|---|---| +| Language | Python 3.13 | +| API | FastAPI, Pydantic v2 | +| DB | SQLite, SQLAlchemy Async, Alembic | +| HTTP | httpx | +| Scheduler | APScheduler | +| HTML 분석 | BeautifulSoup4, lxml | +| 도메인 분석 | tldextract, RDAP | +| AI | OpenAI Chat Completions, NullAIProvider fallback | +| 테스트 | pytest, pytest-asyncio | +| 품질 도구 | Ruff, mypy | +| 선택 기능 | Playwright 렌더링 분석 | ## 디렉토리 구조 -``` +```text linclean-fastapi/ -├── app/ -│ ├── main.py # FastAPI 앱 팩토리 + lifespan -│ │ -│ ├── api/ # HTTP 계층 -│ │ ├── deps.py # 공용 의존성 (DBSession 등) -│ │ ├── error_handlers.py # 글로벌 예외 → ErrorResponse 변환 -│ │ └── v1/ -│ │ ├── router.py # v1 라우터 집합 -│ │ └── endpoints/ -│ │ ├── analyze.py # /analyze 비동기 접수, /analyze/sync 동기 실행 -│ │ ├── health.py # /health, /health/ready -│ │ └── stages.py # 단계별 운영/QA 엔드포인트 -│ │ -│ ├── core/ # 인프라/공통 -│ │ ├── config.py # 환경변수 (pydantic-settings) -│ │ ├── dns_cache.py # fetch / unchain 공용 DNS TTL 캐시 -│ │ ├── logging.py # structlog + stdlib bridge -│ │ ├── scheduler.py # APScheduler 기반 URLhaus 주기 동기화 -│ │ └── exceptions.py # AppError 도메인 예외 계층 -│ │ -│ ├── db/ # 영속성 계층 (외부 피드 캐시 전용) -│ │ ├── base.py # DeclarativeBase + naming convention -│ │ └── session.py # async engine, get_db, SQLite PRAGMA -│ │ -│ ├── middleware/ -│ │ └── request_context.py # X-Request-ID + 구조적 access log -│ │ -│ ├── models/ # SQLAlchemy ORM 모델 (외부 피드 캐시) -│ │ ├── __init__.py # Alembic autogen 용 import 모음 -│ │ └── urlhaus_entry.py # URLhaus 로컬 캐시 테이블 -│ │ -│ ├── schemas/ # Pydantic DTO -│ │ ├── common.py # HealthResponse, ErrorResponse 등 -│ │ ├── analyze.py # /analyze 요청/접수 응답 -│ │ ├── analysis.py # 하위 호환 re-export -│ │ ├── content_analysis.py # Stage 4 콘텐츠 분석 DTO -│ │ ├── domain_heuristic.py # Stage 3 도메인 휴리스틱 DTO -│ │ ├── normalize.py # Stage 1 정규화 DTO -│ │ ├── pipeline.py # PipelineSuccess / PipelineFailure / Verdict -│ │ ├── threat_db.py # Stage 2 GSB / URLhaus DTO -│ │ └── unchain.py # 리다이렉트 체인 DTO -│ │ -│ └── services/ # 도메인/비즈니스 로직 (파이프라인 단계별 모듈) -│ ├── pipeline.py # 1~4단계 오케스트레이터, 병렬화/short-circuit -│ ├── analysis_callback.py # Spring /internal/analysis-result 콜백 전송 -│ ├── normalizer/ # 1단계: URL 정규화(Canonicalization) -│ │ ├── __init__.py # 진입점 (normalize_url re-export) -│ │ └── normalize.py # 입력 검증, 스킴·호스트 정규화, 포트·프래그먼트 제거, 퍼센트 인코딩 정돈, 경로 정규화, IDN 디코딩 -│ ├── unchainer/ # 1단계 후반: URL 언체이닝(리다이렉트 추적) -│ │ ├── __init__.py # 진입점 (unchain_url re-export) -│ │ └── unchain.py # HEAD+GET 폴백, 체인 총 timeout, SSRF 방어, 의심 신호 수집 -│ ├── threat_db/ # 2단계: 외부 위협 DB 대조 -│ │ ├── __init__.py # 진입점 (check_threat_db re-export) -│ │ ├── check.py # GSB + URLhaus 병렬 조회·병합 -│ │ ├── gsb.py # Google Safe Browsing Lookup API -│ │ ├── urlhaus.py # 로컬 SQLite 조회 -│ │ ├── urlhaus_sync.py # CSV 다운로드 → SQLite upsert -│ │ └── match_keys.py # URLhaus 매칭 키 생성 (host / host+path) -│ ├── domain_heuristic/ # 3단계: 도메인 기반 휴리스틱 분석 -│ │ ├── __init__.py # 진입점 (check_domain_heuristic re-export) -│ │ ├── check.py # 패턴/DGA/타이포/RDAP 조합 및 점수 캡 -│ │ ├── patterns.py # IP 직접 접근, TLD, HTTPS, 하위도메인, 오픈 리다이렉트 -│ │ ├── dga.py # 엔트로피/자음 비율 기반 DGA 후보 탐지 -│ │ ├── typosquatting.py # brands.txt 기반 유사 브랜드 도메인 탐지 -│ │ ├── rdap.py # RDAP 조회, in-flight 병합, TTL/LRU 캐시 -│ │ └── brands.txt # 보호 브랜드 도메인 목록 -│ └── content_analyzer/ # 4단계: 페이지 콘텐츠 정적 분석 + AI 보조 판정 -│ ├── __init__.py # 진입점 (analyze_content re-export) -│ ├── analyze.py # fetch · extract · signals · AI 결과 병합 -│ ├── fetch.py # HTML fetch, content-type/size 컷, SSRF 방어 -│ ├── extract.py # BeautifulSoup+lxml 기반 title/input/meta/link/img 추출 -│ ├── signals.py # 브랜드 위장, meta refresh, 외부 링크 과다 등 규칙 점수 -│ ├── ai.py # AIProvider Protocol, NullAIProvider, 프롬프트 컨텍스트 -│ └── ai_openai.py # OpenAI Structured Outputs 기반 구현체 -│ -├── alembic/ # 외부 피드 캐시 스키마 마이그레이션 -│ ├── env.py # SQLite + batch mode 설정 -│ ├── script.py.mako -│ └── versions/ -│ -├── tests/ # pytest 테스트 -│ ├── conftest.py # 공용 픽스처 -│ ├── demo/ # 데모 스크립트 -│ │ ├── demo_normalize.py # URL 정규화 데모 -│ │ ├── demo_unchain.py # URL 언체이닝 데모 -│ │ ├── demo_threat_db.py # 외부 위협 DB 대조 데모 -│ │ ├── demo_domain_heuristic.py # 도메인 휴리스틱 데모 -│ │ └── demo_content_analysis.py # 콘텐츠 분석 데모 -│ ├── api/ -│ │ ├── test_analyze_callback.py # /analyze background callback 연결 테스트 -│ │ └── test_stages.py # 단계별 API 인증/응답 테스트 -│ └── services/ -│ ├── test_pipeline.py # 전체 파이프라인 오케스트레이션 테스트 -│ ├── test_analysis_callback.py # Spring 콜백 payload/retry 테스트 -│ ├── normalizer/ -│ │ └── test_normalize.py # URL 정규화 단위 테스트 -│ ├── unchainer/ -│ │ └── test_unchain.py # URL 언체이닝 단위 테스트 -│ ├── threat_db/ -│ │ ├── test_match_keys.py # URLhaus 매칭 키 단위 테스트 -│ │ ├── test_gsb.py # GSB Lookup 단위 테스트 -│ │ ├── test_urlhaus.py # URLhaus 조회 단위 테스트 -│ │ ├── test_urlhaus_sync.py # URLhaus 동기화 단위 테스트 -│ │ └── test_check.py # 병렬 조회·판정·폴백 단위 테스트 -│ ├── domain_heuristic/ -│ │ ├── test_check.py # 휴리스틱 통합 점수/신호 테스트 -│ │ ├── test_dga.py # DGA 후보 탐지 테스트 -│ │ ├── test_patterns.py # 도메인 패턴 신호 테스트 -│ │ ├── test_rdap.py # RDAP 파싱/캐시 테스트 -│ │ └── test_typosquatting.py # 브랜드 유사 도메인 테스트 -│ └── content_analyzer/ -│ ├── test_analyze.py # fetch/extract/signals/AI 통합 테스트 -│ ├── test_fetch.py # HTML fetch, SSRF, content-type/size 테스트 -│ ├── test_extract.py # HTML feature 추출 테스트 -│ ├── test_signals.py # 콘텐츠 규칙 점수 테스트 -│ ├── test_ai.py # AI provider protocol/null provider 테스트 -│ └── test_ai_openai.py # OpenAI provider structured output 테스트 -│ -├── data/ # SQLite 캐시 파일 (gitignore) -│ -├── alembic.ini -├── pyproject.toml # 의존성 + ruff/mypy/pytest 설정 -├── Makefile # install / run / test / migrate ... -├── .pre-commit-config.yaml -├── .env.example -└── README.md + app/ + api/ # FastAPI 라우터, 인증 의존성, 에러 핸들러 + core/ # 설정, 로깅, 스케줄러, DNS 캐시 + db/ # SQLAlchemy async engine/session + middleware/ # request_id, access log + models/ # URLhaus 캐시용 ORM 모델 + schemas/ # Pydantic 요청/응답 모델 + services/ + normalizer/ # URL 정규화 + unchainer/ # 리다이렉트 추적 + threat_db/ # GSB, URLhaus 조회 + domain_heuristic/ # 도메인/URL 휴리스틱 + content_analyzer/ # HTML fetch/extract/signals/AI + pipeline.py # DB 의존 전체 파이프라인 + db_independent_pipeline.py + analysis_callback.py # Spring 콜백 + page_unavailability.py + alembic/ # DB migration + data/ # SQLite 파일 위치 + reports/ # 날짜별 평가 결과, 커밋 제외 + scripts/ # 평가/운영 스크립트, 커밋 제외 + tests/ # 단위/통합 테스트 ``` -### 계층별 책임 - -- **`api/`** — HTTP 입출력만 담당. 라우터는 얇게 유지하고, 비즈니스 로직은 - `services/` 에 위임합니다. 의존성(`Depends`)은 `api/deps.py` 에 모아둡니다. -- **`core/`** — 프레임워크에 종속되지 않는 인프라 코드. 설정 로드, 로깅 구성, - 도메인 예외(`AppError`) 등 어디서든 import 해도 안전한 모듈만 둡니다. -- **`db/`** — SQLAlchemy 엔진/세션과 `Base`. **여기서 다루는 것은 외부 위협 - 피드 캐시뿐입니다.** 비즈니스 엔티티(User, Link, Directory 등)는 만들지 - 마세요. SQLite 전용 PRAGMA(`WAL`, `foreign_keys=ON`, `synchronous=NORMAL`) - 가 연결마다 자동 적용됩니다. -- **`models/`** — ORM 모델. 새 모델을 추가하면 반드시 - `app/models/__init__.py` 에서 import 해야 Alembic autogenerate 가 인식합니다. -- **`schemas/`** — 요청·응답 Pydantic 모델. Spring 콜백 본문은 - `services/analysis_callback.py` 가 `PipelineSuccess` / `PipelineFailure` 결과를 - 기반으로 조립합니다. -- **`services/`** — 4단계 파이프라인을 **단계별 하위 패키지**로 분리합니다. - 각 패키지의 `__init__.py` 가 해당 단계의 public 진입점을 re-export 하며, - 오케스트레이터(`pipeline.py`)가 이를 조립합니다. `Request` 같은 FastAPI - 객체를 받지 않고 `AsyncSession` / 순수 인자만 받습니다. - - **`normalizer/`** — 1단계. `normalize_url()` 로 URL 을 canonical form 으로 - 정규화합니다 (앞뒤 공백 제거, 스킴·호스트 소문자화, 기본 포트 제거, - 퍼센트 인코딩 정돈, 경로 dot-segment 해소, IDN 디코딩, 프래그먼트 제거, - 입력 검증). 스킴이 없는 입력은 먼저 `https://` 로 분석 가능한 정상 HTML - 응답인지 확인하고, 그렇지 않으면 `http://` 로 내려 분석합니다. - - **`unchainer/`** — 1단계 후반. `unchain_url()` 로 리다이렉트 체인(3xx Location) - 을 끝까지 추적해 최종 URL 을 확정합니다. HEAD 우선 → GET 폴백 전략으로 - 대역폭을 절약하면서 호환성을 확보하고, 네트워크 에러 시에도 GET 으로 - 재시도합니다. 체인 전체에 총 timeout 을 적용해 악의적 서버 방어가 가능하며, - `javascript:` / `data:` 같은 비허용 스킴 리다이렉트를 차단합니다. - 스킴 다운그레이드·크로스 오리진 등의 의심 신호도 수집합니다. - - **`threat_db/`** — 2단계. GSB 실시간 조회와 URLhaus 로컬 SQLite 조회를 - 병렬로 수행하고 결과를 `ThreatDbResult` 로 병합합니다. URLhaus 동기화는 - CSV 다운로드 후 chunk 단위 upsert 로 부분 진행을 보존합니다. - - **`domain_heuristic/`** — 3단계. 등록 가능 도메인을 기준으로 RDAP 등록일, - 오타 도메인, suspicious TLD, DGA 후보, 오픈 리다이렉트 파라미터, 하위도메인 - 과다 사용 등을 점수화합니다. RDAP 조회는 TTL/LRU 캐시와 in-flight 병합으로 - 외부 호출 수를 제한합니다. - - **`content_analyzer/`** — 4단계. 최종 URL의 HTML만 fetch 하고, lxml 기반 - 정적 추출 결과를 규칙 점수와 AI 보조 판정으로 합성합니다. 네트워크/AI 실패는 - degraded 결과로 흡수하되 `CancelledError` 는 상위로 전파합니다. - - **`pipeline.py`** — 1~4단계를 조립합니다. 2·3단계를 병렬 실행하고, - 외부 위협 DB 매치나 danger 임계 도달 시 4단계를 시작하지 않고 - short-circuit 합니다. AI 판정은 선행 단계 신호가 준비된 뒤에만 수행됩니다. - - **`analysis_callback.py`** — 비동기 `/analyze` 완료 후 Spring 내부 콜백 - 엔드포인트로 결과를 POST 합니다. 2xx 외 응답/네트워크 오류는 최대 3회 - 재시도하고, 최종 실패는 dead-letter 로그로 남깁니다. -- **`middleware/`** — `RequestContextMiddleware` 가 매 요청마다 `X-Request-ID` - 를 생성/전파하고 structlog contextvars 에 바인딩합니다. 응답 헤더로도 echo - 되며 모든 로그 라인에 자동으로 따라붙습니다. -- **`alembic/`** — SQLite 의 제한적 ALTER 지원을 보완하기 위해 - `render_as_batch=True` 로 동작합니다. - ---- - -## 핵심 — URL 안전성 분석 4단계 파이프라인 - -``` -URL 입력 (Spring 으로부터 위임) - │ - ▼ -1단계: URL 정규화 + 단축 URL 언체이닝 ← 스킴·호스트 소문자, 기본 포트/프래그먼트 - │ 제거, 리다이렉트 체인 추적해 최종 URL 확정 - ▼ -2단계: 외부 위협 DB 대조 ← GSB(API) + URLhaus(로컬 SQLite) - │ (최종 URL 기준 대조 — 블랙리스트 매치 시 즉시 short-circuit 가능) - │ - ▼ -3단계: 도메인 휴리스틱 분석 ← RDAP(등록일·레지스트라), 오타 도메인, 패턴 - │ - ▼ -4단계: 페이지 콘텐츠 정적 분석 ← BeautifulSoup + AI API (피싱 신호 추론) - │ - ▼ -종합 위험 점수 산출 → 안전 / 주의 / 위험 판정 - │ - ▼ -Spring `/internal/analysis-result` 콜백 POST +## 파이프라인 구조 + +```text +입력 URL + -> 1. URL 정규화 + -> 2. 리다이렉트 언체이닝 + -> 3. 외부 위협 DB 조회 + -> 4. 도메인/URL 휴리스틱 + -> 5. 콘텐츠 분석 + -> 6. AI 보조 판정 + -> 점수 합산 및 verdict 산출 + -> 동기 응답 또는 Spring 콜백 ``` -### 1단계 — URL 정규화 + 단축 URL 언체이닝 - -외부 DB 대조와 도메인 분석이 의미를 가지려면 **어떤 URL 을 검사할지부터 -확정** 해야 합니다. `bit.ly` 같은 단축 URL 상태로 GSB / URLhaus 를 조회하면 -거의 항상 매치되지 않기 때문에, 모든 후속 단계의 입력이 되는 "최종 URL" -을 먼저 만듭니다. - -- **정규화**: 스킴·호스트 소문자화, 기본 포트(`:80` / `:443`) 제거, - 프래그먼트(`#...`) 제거, 퍼센트 인코딩 정돈, 추적 파라미터(`utm_*` 등) - 정책적 제거, IDN(퓨니코드) → 유니코드 정규화 -- **언체이닝**: `HEAD` 우선 → `GET` 폴백 전략으로 리다이렉트 체인(3xx Location) - 을 끝까지 따라가 **최종 분석 대상 URL** 을 확정합니다. - - 네트워크 에러(타임아웃·연결 실패 등) 발생 시에도 GET 으로 재시도 - - 체인 전체에 총 timeout(기본 30초) 적용 — 악의적 서버의 지연 공격 방어 - - `javascript:`, `data:` 등 비허용 스킴 리다이렉트 차단 - - 스킴 다운그레이드(HTTPS→HTTP), 크로스 오리진, 무한 루프, max hop 초과 감지 - - 각 hop 의 원본 Location 값(`raw_location`)과 절대경로 해석 결과를 모두 기록 -- 이후 2~4 단계는 모두 이 **최종 URL** 을 기준으로 동작합니다. - -### 2단계 — 외부 위협 DB 대조 - -1단계에서 확정된 최종 URL 을 두 개의 위협 피드와 병렬로 대조합니다. 자체 -휴리스틱보다 먼저 실행해 이미 알려진 악성 URL 이면 조기에 `danger` 로 -short-circuit 할 수 있습니다. - -| 소스 | 방식 | 응답 시간 | 탐지 대상 | -|------|------|-----------|-----------| -| **Google Safe Browsing** | Google API 실시간 조회 | 100~300ms | 피싱 / 멀웨어 / 소셜 엔지니어링 | -| **URLhaus (abuse.ch)** | CSV → **로컬 SQLite** 캐시 | 1~5ms | 멀웨어 배포 URL | - -URLhaus 데이터는 APScheduler 가 주기적으로 CSV 를 다운로드해 로컬 SQLite 에 -upsert 합니다. 분석 시에는 외부 호출 없이 로컬 인덱스만 조회합니다. 두 소스 -중 하나라도 매치되면 그 자체로 강한 위험 신호이며, 점수 가산과 함께 후속 -단계에 결과를 그대로 전달합니다. - -**구현 노트 (`services/threat_db/`):** - -- **외부 의존성 실패는 파이프라인을 죽이지 않습니다.** GSB / URLhaus / DB / - 스케줄러 어디서 실패해도 `check_threat_db()` 는 항상 `ThreatDbResult` 를 - 반환하며, 실패 사유는 `error` 필드에 문자열 코드로 기록됩니다. GSB 만 실패한 - 경우 URLhaus 결과 단독으로 `is_malicious` 를 판정하고, 둘 다 실패하면 - `sources_checked=0` 으로 반환해 상위 레이어가 보수적으로 처리할 수 있게 합니다. -- **URLhaus 매칭 키**: 기본적으로 host 한 개를 키로 쓰되, GitHub / GitLab / - Bitbucket / sites.google.com 같은 다중 테넌트 호스트는 - `host + path-prefix(N 세그먼트)` 키를 추가로 생성합니다. 계정·리포 단위에서 - 악성 여부가 갈리는 도메인을 host 전체로 블랙리스트화해 오탐하지 않도록 합니다. - 동기화·조회 모두 동일한 `derive_keys()` 를 사용해 키 일관성을 보장합니다. -- **스케줄러**: `AsyncIOScheduler(timezone=UTC)` 싱글톤이 `urlhaus_sync` 를 - `IntervalTrigger(seconds=urlhaus_refresh_interval_seconds)` 로 주기 실행합니다 - (`coalesce=True, max_instances=1, misfire_grace_time=interval`). 앱 부트 시 - `urlhaus_sync_on_startup=True` 이면 최초 1회 즉시 동기화를 백그라운드로 - 수행합니다. 테스트에서는 `settings.scheduler_enabled=False` 로 전역 비활성화. -- **청크 커밋 동기화**: `sync_urlhaus()` 는 CSV 수만 행을 단일 트랜잭션으로 - 감싸지 않고 `CHUNK_SIZE=500` 단위로 나눠 커밋합니다. 중간 청크에서 DB 오류가 - 나도 직전 청크까지의 결과는 영속화되며, 실패 청크는 롤백되어 `failed` 로 - 누적됩니다. `stats = {inserted, updated, total, failed}` 는 실제 커밋된 - 행 수만 반영하므로 재시도 대상 판정에 그대로 쓸 수 있습니다. insert/update - 분류는 청크 직전에 `SELECT` 로 기존 id 를 조회해 정확히 분리합니다 - (SQLite `ON CONFLICT DO UPDATE` 는 cursor 로 두 경로 구분 불가). -- **CancelledError 전파**: `check_threat_db()` 내부 `asyncio.gather(..., - return_exceptions=True)` 는 일반 예외만 degraded 결과로 흡수하고, - `CancelledError` 는 그대로 re-raise 합니다. 상위 shutdown / 요청 타임아웃 - 신호를 삼키면 degraded 결과가 영속화될 위험이 있기 때문입니다. - -### 3단계 — 도메인 휴리스틱 분석 - -규칙 기반 점수표로 도메인의 위험 신호를 합산합니다. 도메인 등록 정보는 -**RDAP (RFC 7480~7484)** 로 조회합니다. - -**2단계·3단계 동시 실행 + 외부 DB 매치 시 조기 종료**: 2·3단계는 1단계 -최종 URL만 필요하고 서로 독립이라 `run_pipeline` 에서 동시에 띄웁니다. -4단계는 threat DB/RDAP 신호가 확정되고 danger short-circuit 대상이 아닐 때만 -시작합니다. - -- **GSB 또는 URLhaus 매치 (`threat_db.is_malicious=True`)** 가 먼저 떨어지면, - 아직 RDAP 대기 중일 수 있는 task 를 **즉시 `cancel()`** 하고, 4단계는 - 시작하지 않은 채 `skipped_already_danger` 로 묶어 바로 반환합니다. 알려진 - 악성 URL 은 score 100 / verdict danger 로 확정되므로 페이지 fetch 나 AI 호출을 - 수행하지 않습니다. - heuristic 자리에는 `skipped_reason="threat_matched"` 인 placeholder 가 채워져 - 응답 스키마를 유지합니다. -- **heuristic 이 먼저 끝난 경우**는 threat_db 를 마저 기다린 뒤, is_malicious - 이거나 합산 점수가 임계를 넘으면 4단계만 skip 합니다. -- **정상 경로**에서는 GSB 와 RDAP(캐시 미스 시 기본 최대 3s)의 latency 가 - 겹칩니다. 이후 danger 임계 미만일 때만 콘텐츠 fetch/extract 와 AI 분석을 - 수행합니다. -- `CancelledError` 와 stage 내부 예외는 남은 task 를 정리한 뒤 상위로 전파되어 - shutdown / 타임아웃 신호가 degraded 결과로 삼켜지지 않습니다. - -| 검사 항목 | 위험 신호 예시 | 점수 | -|----------|----------------|------| -| IP 직접 접근 | `http://192.168.x.x/login` | +40 | -| 오타 도메인 (레벤슈타인 거리 1~2) | `naverr.com`, `naaver.com` | +40 | -| punycode / IDN 호모글리프 | `xn--naver-xxx.com` | +35 | -| HTTPS 미사용 | `http://` | +30 | -| 신규 도메인 (RDAP 등록 30일 미만) | `created_date` 기준 | +30 | -| 서브도메인 과다 중첩 | `signin.auth.naver.attacker.xyz` | +25 | -| 특수문자·하이픈 과다 | `login-secure-naver-auth.com` | +20 | -| 의심 TLD | `.zip`, `.mov`, `.xyz`, `.top` 등 | +20 | -| 오픈 리다이렉트 파라미터 | `?url=`, `?redirect=` | +20 | -| DGA 의심 도메인 | Shannon 엔트로피 ≥ 3.5 또는 자음 비율 ≥ 0.7 | +15 | -| 합법 호스팅 플랫폼 (공유 호스팅 주의 가중치) | `user.github.io`, `app.netlify.app` | +15 | - -레벤슈타인 거리 함수는 외부 라이브러리에 의존하지 않고 직접 구현합니다 (DP). 약 500개 브랜드 화이트리스트(`brands.txt`)와 비교합니다. DGA 탐지는 Shannon 엔트로피와 자음 비율 통계만 사용하며 외부 모델이 필요 없습니다. RDAP 응답은 도메인 단위로 인메모리 캐싱(7일, `rdap_cache_ttl_seconds`)하여 동일 도메인 재조회 비용을 줄입니다. 캐시 만료·미스 순간에도 같은 도메인으로 몰리는 요청은 `_inflight` dict + `asyncio.Future` 로 합쳐(**single-flight / request coalescing**) RDAP 서버로 나가는 HTTP 호출을 1건으로 수렴시킵니다. RDAP 서버가 429 를 반환하면 `Retry-After` 또는 기본 쿨다운 동안 추가 RDAP 호출을 건너뛰고 `rdap_error="rate_limited"` 로 내려 호출량을 제한합니다. RDAP 실패 시 신생 도메인 신호를 발동하지 않습니다 ("모름"을 "위험"으로 취급하지 않는 원칙). - -`HOSTING_PLATFORM` 은 "이 도메인이 악성이다" 라는 신호가 아니라 **공유 호스팅 컨텍스트**(GitHub Pages·Netlify·Vercel·Heroku 등 다수 테넌트가 같은 상위 도메인을 공유)를 나타내는 주의 가중치입니다. URLhaus 매칭 키가 `host + path-prefix` 로 확장되는 것과 같은 맥락에서, 계정·리포 단위로 악성 여부가 갈리는 환경이므로 +15 를 가산합니다. 플랫폼 루트 도메인 자체(`netlify.app`, `vercel.app` 등)는 정상 운영 도메인이므로 타이포스쿼팅 검사에서 제외됩니다. - -### 4단계 — 페이지 콘텐츠 정적 분석 - -`httpx + BeautifulSoup` 으로 실제 페이지를 크롤링해 HTML 구조를 추출하고, -**OpenAI Chat Completions API** 가 피싱 신호를 정적으로 추론합니다. AI 는 본 -엔진 안에서 이 정적 분석 단계에서만 사용됩니다. - -추출 / 점수화하는 신호: - -- **로그인 폼 + 브랜드 위장**: `` 가 있고 title 에는 - 유명 브랜드명이 있는데 도메인은 그 브랜드와 무관 (+50) -- **브랜드 로고 이미지 위장**: `...` 의 alt 텍스트와 도메인 불일치 (+30) -- **`meta refresh` 자동 리다이렉트** (+20) -- **외부 링크 비율 과다**: 80% 이상이 외부 도메인이면 (+15) -- **AI 정적 추론**: 추출된 텍스트·폼·메타데이터를 AI 에게 넘겨 "이 페이지가 - 특정 브랜드를 사칭하거나 자격 증명을 탈취하려 하는지" 여부와 근거 텍스트를 - 반환받아 점수에 반영 (phishing +40 / suspicious +20 / benign 0) -- **`SPA_SHELL`** (점수 0, 시그널만): 초기 HTML 이 React/Vue/Next/Nuxt/Svelte/Angular - 마운트 셸뿐이라 정적 추출로 폼·입력 판정이 결정적이지 않은 상태. `is_spa_shell=true` - 로 응답에 실리고, AI 프롬프트에도 힌트로 전달돼 모델이 "폼 없음" 으로 단정하지 - 않도록 한다. 정상 SPA 가 압도적으로 많아 점수 가산은 하지 않는다. -- **페이지 접근 실패** — 사유별 가산 분리: - - `timeout` / `connect_error` / `http_error_*` / `unexpected` (도달 자체 실패): **+10** - - `not_html` (PDF/이미지 등 정상 비-HTML), `too_large` (대용량 정상 페이지), - `unexpected_redirect` (unchainer 가 놓친 3xx — 파이프라인 정합성 이슈): **+0** (시그널만) - - `blocked_host` (사설/loopback IP 또는 클라우드 메타데이터 호스트): **+10** (SSRF 1선 차단) - -#### 브랜드 매칭 전략 — 영문은 단어 경계, 한국어는 substring - -`brands.txt` (약 600개) 의 라벨로 title/alt 텍스트를 매칭할 때, 영문 라벨은 -`\b