Skip to content

feat: 나이브 베이즈+Gemini 하이브리드 & 규칙 기반 트랙 스코어링 개편 (#19)#20

Open
kite-pp wants to merge 7 commits into
developfrom
feat/19-hybrid-scoring-pipeline
Open

feat: 나이브 베이즈+Gemini 하이브리드 & 규칙 기반 트랙 스코어링 개편 (#19)#20
kite-pp wants to merge 7 commits into
developfrom
feat/19-hybrid-scoring-pipeline

Conversation

@kite-pp

@kite-pp kite-pp commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • 1차 나이브 베이즈(로컬/무료) → SAFE 미만일 때만 Gemini 2차 검증하는 하이브리드 텍스트 트랙 구축
  • 신규 규칙 기반 트랙(금융기관 DB 대조, 금융 키워드, 계좌/카드번호 패턴, 로컬 도메인 룰) 추가
  • URL 없을 때 LLM 65% + 규칙 35%로 트랙 가중치 재분배 (텍스트 전용 스미싱이 MEDIUM에 갇히던 구조적 결함 해소)
  • GSB 블랙리스트 등재 / VT 다수 엔진 합의(5개↑) / 로컬 도메인 룰 중 하나라도 확정되면 HIGH 강제 오버라이드
  • VirusTotal 점수를 개수 기반 임의 공식에서 실제 탐지 엔진 비율 기반으로 재계산
  • URL 리다이렉트 추적기: 상대경로 처리 버그 수정 + SSRF 방어(사설 IP/루프백/클라우드 메타데이터 차단) 추가
  • Swagger 문서화 엔드포인트(POST /api/analyze) 기준 E2E 통합 테스트 추가

세부 내용은 SCORING_PIPELINE_CHANGES.md 참고.

Test plan

  • pytest tests/ 신규/수정 대상 59개 전부 통과 (naive bayes, rule engine, hybrid url engine, virustotal, scoring engine, scan service, url tracer, e2e)
  • 실제 Gemini API로 나이브 베이즈 오탐 사례("엄마 오늘 저녁 메뉴 뭐야?")가 최종 점수에서 정상적으로 완화되는지 수동 검증
  • URL 없는 실제 스미싱 문자가 규칙 트랙과 결합해 HIGH까지 도달하는지 수동 검증
  • Spring Boot Gateway와의 실제 연동 스모크 테스트 (DTO 스키마는 하위호환 유지했으나 실 연동 확인 필요)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added hybrid, multi-stage phishing detection for message text with smarter escalation to AI review when Naive Bayes signals require it.
    • Added deterministic rule-based analysis with transparent rule_analysis in results.
    • Enhanced URL tracing for safer redirects (including SSRF-style internal destination blocking) and improved malicious confirmation using an engine-count threshold.
  • Documentation

    • Published an overview of the redesigned scoring pipeline and expected end-to-end test coverage.
  • Tests

    • Expanded endpoint, scoring engine, Naive Bayes, rules, URL tracing, VirusTotal, and hybrid URL confirmation coverage, including fail-safe scenarios.

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kite-pp, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 18 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d7c3c8c3-f28c-4c79-a1aa-9287edd666ae

📥 Commits

Reviewing files that changed from the base of the PR and between 5bcacef and 1e37185.

📒 Files selected for processing (13)
  • app/service/scan_service.py
  • app/service/security/parser.py
  • app/utils/scoring_engine.py
  • app/utils/url_tracker.py
  • pytest.ini
  • tests/e2e/test_analyze_endpoint.py
  • tests/security/test_gemini_text_analyzer.py
  • tests/security/test_scoring_engine.py
  • tests/security/test_security_engine.py
  • tests/service/test_scan_service.py
  • tests/url/test_url_extractor.py
  • tests/url/test_url_tracer.py
  • tests/url/test_url_tracer_integration.py
📝 Walkthrough

Walkthrough

The PR restructures SMS analysis around Naive Bayes, conditional Gemini escalation, deterministic rules, safer URL tracking, revised VirusTotal scoring, malicious overrides, and URL-aware weighted scoring. It expands response details and adds unit, service, security, URL, and end-to-end coverage.

Changes

SMS scoring pipeline

Layer / File(s) Summary
Text analyzers and rule signals
app/service/security/naive_bayes_text_analyzer.py, app/service/security/rule_based_analyzer.py, tests/security/*, requirements.txt
Adds fail-safe Naive Bayes inference, deterministic rule scoring, required model dependencies, and focused tests.
URL tracking and threat confirmation
app/utils/url_tracker.py, app/service/security/hybrid_url_engine.py, app/service/security/virustotal.py, tests/url/*
Adds SSRF-aware redirect traversal, broader relative redirect handling, VT ratio scoring, confirmation flags, and security tests.
Hybrid orchestration and weighted scoring
app/service/scan_service.py, app/utils/scoring_engine.py, app/dto/schemas.py, SCORING_PIPELINE_CHANGES.md, tests/service/*
Integrates conditional Gemini escalation, rule analysis, malicious overrides, URL-dependent weights, expanded contribution limits, and response details.
Endpoint contract validation
tests/e2e/test_analyze_endpoint.py
Validates analysis scenarios, schemas, request errors, health checks, and OpenAPI exposure with external integrations mocked.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: feat

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: a Naive Bayes + Gemini hybrid pipeline with rule-based scoring overhaul.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/19-hybrid-scoring-pipeline

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ack (#19)

Replace the single-track Gemini-only text analysis with a two-stage
naive bayes (local, free) -> Gemini (2nd-pass) pipeline, add a
deterministic rule-based track (financial institution DB, keyword
categories, account/card number patterns), and rework the 3-way
scoring engine to reweight tracks when no URL is present and to
hard-override to HIGH on confirmed-malicious signals (GSB blacklist,
VT multi-engine consensus, local domain rules). Also fixes an SSRF gap
and a relative-redirect bug in the URL tracer, and switches VirusTotal
scoring to an actual detection-ratio formula instead of a raw count
heuristic.
@kite-pp
kite-pp force-pushed the feat/19-hybrid-scoring-pipeline branch from c62548f to be3fb98 Compare July 24, 2026 02:39

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/utils/scoring_engine.py (1)

105-128: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

contribution_breakdown doesn't reconcile with final_score after the confirmed-malicious override.

When is_confirmed_malicious=True raises final_score to max(final_score, 70), the individual llm_contrib/url_contrib/rules_contrib used to build breakdown (returned in SmishingAnalysisResponse.contribution_breakdown) are never adjusted. E.g. with llm_score=5, url_risk_score=0.95, rule_score=0, is_confirmed_malicious=True (mirroring the existing test at tests/security/test_scoring_engine.py lines 57-72), the raw weighted sum is well under 70, yet final_score is forced to 70+ while breakdown still reflects the pre-override (much lower) contributions — so breakdown.llm + breakdown.hybrid_url + breakdown.rules != final_score. Since the schema (app/dto/schemas.py ContributionBreakdown) documents this field as a "3개 레이어별 점수 기여도 명세" (per-layer contribution spec), API consumers relying on the breakdown to explain/reconstruct the score will see an inconsistent contract in override cases.

♻️ Proposed fix: reflect the override in the breakdown (or document the discrepancy explicitly)
         if is_confirmed_malicious:
             if risk_grade != RiskGrade.HIGH:
                 logger.warning(
                     f"[Scoring Engine] 확정 악성 URL 감지 -> HIGH 등급 강제 오버라이드 (원래 점수: {final_score})"
                 )
+                # 오버라이드로 final_score가 상승한 만큼 breakdown에도 반영 (예: rules_contrib 등 대표 트랙에 반영,
+                # 혹은 별도의 override 근거 필드를 응답에 추가해 소비자가 불일치를 인지할 수 있게 함)
             final_score = max(final_score, 70)
             risk_grade = RiskGrade.HIGH

At minimum, consider adding an explicit flag (e.g. override_applied: bool) to the response so consumers know the breakdown doesn't fully explain final_score in this case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/utils/scoring_engine.py` around lines 105 - 128, Update the
confirmed-malicious override flow around is_confirmed_malicious and
ContributionBreakdown so contribution_breakdown remains consistent with
final_score after forcing the HIGH threshold. Adjust or otherwise explicitly
represent the override in the per-layer contribution data, and update the
related schema/response contract as needed so consumers can reconstruct or
identify the overridden score.
🧹 Nitpick comments (4)
tests/security/test_hybrid_url_engine.py (1)

40-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing boundary test at the VT consensus threshold (detected_count == 5).

Tests cover 2 (below) and 7 (above) but not exactly 5, per the doc's "5개 이상" (≥5) threshold. Without it, an off-by-one (> vs >=) in the actual comparison wouldn't be caught.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/security/test_hybrid_url_engine.py` around lines 40 - 66, Extend the VT
consensus tests around HybridUrlEngine.scan_url with a case where detected_count
is exactly 5, keeping the other scan mocks consistent with the existing
threshold tests, and assert that the result sets is_vt_confirmed to True.
app/service/security/hybrid_url_engine.py (1)

72-74: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Defensive base_score > 1.0 guard is now unreachable.

VirusTotalEngine.scan_url always returns a ratio capped at 1.0 (min(malicious_ratio + suspicious_ratio*0.5, 1.0)), so this normalization branch can no longer trigger. Harmless, but worth removing/documenting as legacy if a future refactor touches this area.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/service/security/hybrid_url_engine.py` around lines 72 - 74, Remove the
unreachable base_score > 1.0 normalization branch from the risk-score
calculation in VirusTotalEngine.scan_url, leaving risk_score to use the capped
base_score directly with the existing max/min logic.
app/service/security/naive_bayes_text_analyzer.py (2)

6-6: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cross-module coupling for a generic helper.

determine_text_risk_grade is imported from gemini_text_analyzer even though it's now shared by both the naive-bayes and Gemini tracks. Consider moving it to a shared module (e.g. text_risk_grading.py) to avoid the naive-bayes analyzer depending on a module named after an unrelated engine.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/service/security/naive_bayes_text_analyzer.py` at line 6, Move the shared
determine_text_risk_grade helper out of gemini_text_analyzer into a neutral
shared module such as text_risk_grading.py, then update both the naive-bayes
analyzer and Gemini analyzer imports to use that module while preserving
existing behavior.

73-77: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Deserializing pickled model artifacts with joblib.load.

joblib.load executes arbitrary code embedded in the pickle stream. MODEL_PATH/VECTORIZER_PATH are overridable via NAIVE_BAYES_MODEL_PATH/NAIVE_BAYES_VECTORIZER_PATH env vars, so if either the deployment env or the artifact directory is ever writable by a less-trusted actor, loading becomes an RCE vector (CWE-502). Given these are trusted, locally-produced training artifacts today, consider at least validating a checksum/signature before load if the artifact pipeline ever becomes externally sourced (e.g., CI artifact download, shared storage).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/service/security/naive_bayes_text_analyzer.py` around lines 73 - 77,
Harden artifact loading in the analyzer initialization around MODEL_PATH and
VECTORIZER_PATH by validating each artifact’s trusted checksum or signature
before calling joblib.load. Reject mismatched or untrusted files and preserve
the existing model, threshold, classes, and vectorizer initialization only after
both validations succeed.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/utils/scoring_engine.py`:
- Around line 33-53: Update ScoringEngine._combine_text_track_score to check
llm_available when naive_bayes_score is None; preserve the existing llm_score
return only when Gemini is available, and explicitly surface the simultaneous
NB/Gemini failure through the established logging or failure-reporting path
instead of silently returning the default 0.

In `@app/utils/url_tracker.py`:
- Around line 28-58: Update _is_public_host and trace_url so DNS validation
returns and preserves the validated address, then configure the httpx
AsyncClient HEAD/GET requests to connect to that pinned IP while retaining the
original hostname for SNI/Host handling across every redirect hop. Add a timeout
around loop.getaddrinfo, and ensure failed or timed-out resolution remains
blocked rather than falling back to resolving the hostname again.

In `@tests/e2e/test_analyze_endpoint.py`:
- Around line 55-70: Update
test_message_with_url_populates_url_analysis_via_hybrid_engine to mock
app.service.scan_service.trace_url before posting the request, while preserving
the existing URL-analysis and hybrid contribution assertions. Do not rely on
live DNS or HTTP behavior; leave redirect coverage to the dedicated URL-tracer
tests.
- Around line 79-91: Strengthen the assertions in the analyze endpoint test:
require hybrid_url to equal 0 for requests without a URL, require rule_analysis
to be present and successful, and validate that the OpenAPI POST operation
defines both a request-body schema and a 200-response schema. Update the
existing contribution_breakdown and schema assertions in the affected test
sections without loosening other scoring checks.
- Around line 38-52: Update
test_phishing_text_without_url_escalates_to_gemini_and_is_high_risk so the
endpoint-level assertion requires body["risk_grade"] to equal "HIGH" rather than
accepting "MEDIUM". Remove the exact stage1_naive_bayes risk_score == 97
assertion from this E2E test, leaving that classifier-internal value to unit
tests while preserving the existing successful response, analysis presence, and
null URL assertions.

---

Outside diff comments:
In `@app/utils/scoring_engine.py`:
- Around line 105-128: Update the confirmed-malicious override flow around
is_confirmed_malicious and ContributionBreakdown so contribution_breakdown
remains consistent with final_score after forcing the HIGH threshold. Adjust or
otherwise explicitly represent the override in the per-layer contribution data,
and update the related schema/response contract as needed so consumers can
reconstruct or identify the overridden score.

---

Nitpick comments:
In `@app/service/security/hybrid_url_engine.py`:
- Around line 72-74: Remove the unreachable base_score > 1.0 normalization
branch from the risk-score calculation in VirusTotalEngine.scan_url, leaving
risk_score to use the capped base_score directly with the existing max/min
logic.

In `@app/service/security/naive_bayes_text_analyzer.py`:
- Line 6: Move the shared determine_text_risk_grade helper out of
gemini_text_analyzer into a neutral shared module such as text_risk_grading.py,
then update both the naive-bayes analyzer and Gemini analyzer imports to use
that module while preserving existing behavior.
- Around line 73-77: Harden artifact loading in the analyzer initialization
around MODEL_PATH and VECTORIZER_PATH by validating each artifact’s trusted
checksum or signature before calling joblib.load. Reject mismatched or untrusted
files and preserve the existing model, threshold, classes, and vectorizer
initialization only after both validations succeed.

In `@tests/security/test_hybrid_url_engine.py`:
- Around line 40-66: Extend the VT consensus tests around
HybridUrlEngine.scan_url with a case where detected_count is exactly 5, keeping
the other scan mocks consistent with the existing threshold tests, and assert
that the result sets is_vt_confirmed to True.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86b29831-cf7b-4965-8cef-c9fb0cfbf1cb

📥 Commits

Reviewing files that changed from the base of the PR and between 6d1fa67 and c62548f.

📒 Files selected for processing (18)
  • SCORING_PIPELINE_CHANGES.md
  • app/dto/schemas.py
  • app/service/scan_service.py
  • app/service/security/hybrid_url_engine.py
  • app/service/security/naive_bayes_text_analyzer.py
  • app/service/security/rule_based_analyzer.py
  • app/service/security/virustotal.py
  • app/utils/scoring_engine.py
  • app/utils/url_tracker.py
  • requirements.txt
  • tests/e2e/test_analyze_endpoint.py
  • tests/security/test_hybrid_url_engine.py
  • tests/security/test_naive_bayes_text_analyzer.py
  • tests/security/test_rule_based_analyzer.py
  • tests/security/test_scoring_engine.py
  • tests/security/test_virustotal.py
  • tests/service/test_scan_service.py
  • tests/url/test_url_tracer.py

Comment thread app/utils/scoring_engine.py
Comment thread app/utils/url_tracker.py Outdated
Comment on lines +38 to +52
def test_phishing_text_without_url_escalates_to_gemini_and_is_high_risk():
"""
시나리오: URL 없이 기관 사칭 + 긴급성 유도 문구만 있는 전형적 스미싱 문자 ->
나이브 베이즈가 의심 판정해 Gemini 2차 검증까지 실행되고, 최종 위험 등급도 높게 나와야 한다.
"""
text = "[검찰청] 귀하 명의로 대포통장이 개설되어 수사가 진행 중입니다. 즉시 아래 링크로 접속하여 신원을 확인하세요."
response = client.post("/api/analyze", json={"text": text})

assert response.status_code == 200
body = response.json()
assert body["status"] == "SUCCESS"
assert "stage1_naive_bayes" in body["text_analysis"]
assert body["text_analysis"]["stage1_naive_bayes"]["risk_score"] == 97
assert body["risk_grade"] in ("MEDIUM", "HIGH")
assert body["url_analysis"] is None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the assertion with the test’s stated HIGH-risk contract.

This test is named is_high_risk, but it accepts MEDIUM, so a regression that downgrades this URL-less phishing case still passes. The exact 97 score also couples this E2E test to classifier internals; keep that exact value in unit tests and assert the endpoint-level behavior here.

Proposed assertion adjustment
-    assert body["text_analysis"]["stage1_naive_bayes"]["risk_score"] == 97
-    assert body["risk_grade"] in ("MEDIUM", "HIGH")
+    assert 0 <= body["text_analysis"]["stage1_naive_bayes"]["risk_score"] <= 100
+    assert body["risk_grade"] == "HIGH"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_phishing_text_without_url_escalates_to_gemini_and_is_high_risk():
"""
시나리오: URL 없이 기관 사칭 + 긴급성 유도 문구만 있는 전형적 스미싱 문자 ->
나이브 베이즈가 의심 판정해 Gemini 2 검증까지 실행되고, 최종 위험 등급도 높게 나와야 한다.
"""
text = "[검찰청] 귀하 명의로 대포통장이 개설되어 수사가 진행 중입니다. 즉시 아래 링크로 접속하여 신원을 확인하세요."
response = client.post("/api/analyze", json={"text": text})
assert response.status_code == 200
body = response.json()
assert body["status"] == "SUCCESS"
assert "stage1_naive_bayes" in body["text_analysis"]
assert body["text_analysis"]["stage1_naive_bayes"]["risk_score"] == 97
assert body["risk_grade"] in ("MEDIUM", "HIGH")
assert body["url_analysis"] is None
def test_phishing_text_without_url_escalates_to_gemini_and_is_high_risk():
"""
시나리오: URL 없이 기관 사칭 + 긴급성 유도 문구만 있는 전형적 스미싱 문자 ->
나이브 베이즈가 의심 판정해 Gemini 2 검증까지 실행되고, 최종 위험 등급도 높게 나와야 한다.
"""
text = "[검찰청] 귀하 명의로 대포통장이 개설되어 수사가 진행 중입니다. 즉시 아래 링크로 접속하여 신원을 확인하세요."
response = client.post("/api/analyze", json={"text": text})
assert response.status_code == 200
body = response.json()
assert body["status"] == "SUCCESS"
assert "stage1_naive_bayes" in body["text_analysis"]
assert 0 <= body["text_analysis"]["stage1_naive_bayes"]["risk_score"] <= 100
assert body["risk_grade"] == "HIGH"
assert body["url_analysis"] is None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/test_analyze_endpoint.py` around lines 38 - 52, Update
test_phishing_text_without_url_escalates_to_gemini_and_is_high_risk so the
endpoint-level assertion requires body["risk_grade"] to equal "HIGH" rather than
accepting "MEDIUM". Remove the exact stage1_naive_bayes risk_score == 97
assertion from this E2E test, leaving that classifier-internal value to unit
tests while preserving the existing successful response, analysis presence, and
null URL assertions.

Comment thread tests/e2e/test_analyze_endpoint.py
Comment on lines +79 to +91
assert {
"status", "message", "final_score", "risk_grade",
"contribution_breakdown", "text_analysis", "url_analysis"
}.issubset(body.keys())

assert 0 <= body["final_score"] <= 100
assert body["risk_grade"] in ("HIGH", "MEDIUM", "LOW")

# URL 없으면 그 30%가 LLM/규칙 트랙으로 재배분되어 상한이 65/35로 늘어남
breakdown = body["contribution_breakdown"]
assert 0 <= breakdown["llm"] <= 65
assert 0 <= breakdown["hybrid_url"] <= 30
assert 0 <= breakdown["rules"] <= 35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Strengthen the schema and scoring contract assertions.

The current checks are too permissive: a no-URL request may still contribute to hybrid_url, rule_analysis can disappear without failing the test, and an OpenAPI operation with no request or response schema would still pass. Assert hybrid_url == 0, require successful rule_analysis, and validate the POST request body plus 200-response schemas.

Also applies to: 111-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/test_analyze_endpoint.py` around lines 79 - 91, Strengthen the
assertions in the analyze endpoint test: require hybrid_url to equal 0 for
requests without a URL, require rule_analysis to be present and successful, and
validate that the OpenAPI POST operation defines both a request-body schema and
a 200-response schema. Update the existing contribution_breakdown and schema
assertions in the affected test sections without loosening other scoring checks.

…eously

_combine_text_track_score returned llm_score whenever naive_bayes_score
was None, without checking llm_available in that branch. Since NB
being unavailable always routes through the Gemini fallback path in
_analyze_text_hybrid, a simultaneous Gemini failure meant llm_score
defaulted to 0 and the text track silently scored as safe - with the
outage only visible if a consumer inspected text_analysis.result.grade.

Add a fallback score (80) used only when both engines are down, sized
so it alone clears the MEDIUM threshold even under the lowest track
weight (URL-present, 50%), so total classifier outage surfaces as
MEDIUM+ instead of being indistinguishable from a genuinely safe
message.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
app/utils/url_tracker.py (1)

16-58: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

DNS-rebinding TOCTOU still present: resolved IP is validated but discarded before the real connection.

_is_public_host resolves and validates the hostname's IPs, but trace_url (lines 86-98) then passes the original hostname string to client.head/client.get, letting httpx re-resolve DNS independently at connection time. An attacker can return a safe IP for the validation lookup and a private/metadata IP for the connection lookup (or simply have DNS flip between the two lookups), bypassing the SSRF guard. This was already raised in a prior review round on this same function; it doesn't look to have been addressed. A durable fix needs a custom httpx transport that pins the connection to the already-validated IP (preserving Host/SNI for the original hostname) rather than re-resolving DNS per hop. Also applies to lines 86-98 where the validated hostname is handed to the client.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/utils/url_tracker.py` around lines 16 - 58, The SSRF validation in
_is_public_host is vulnerable to DNS rebinding because trace_url passes the
hostname to httpx, which resolves it again. Update _is_public_host and the
trace_url request flow to return or retain the validated address, then use a
custom httpx transport that connects to that pinned IP while preserving the
original hostname for the Host header and TLS SNI on every redirect hop.
🧹 Nitpick comments (1)
app/service/security/naive_bayes_text_analyzer.py (1)

90-132: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Synchronous model load/inference blocks the asyncio event loop.

joblib.load (via _load_artifacts) and _model.predict_proba/_vectorizer.transform are CPU/IO-bound synchronous calls executed directly inside async def analyze_text_with_naive_bayes, with no yielding to the loop. Since this coroutine runs concurrently with the Gemini/URL tracks via asyncio.gather in scan_service.py, every inference (and the one-time artifact load) stalls all other concurrently-scheduled requests on this worker for its duration.

Consider offloading to a thread:

♻️ Proposed refactor
     try:
-        import numpy as np
-        from scipy.sparse import csr_matrix, hstack
-
-        text_norm = _normalize_text(text)
-        struct = np.array([_extract_struct_features(text)])
-
-        X_text = _vectorizer.transform([text_norm])
-        X = hstack([X_text, csr_matrix(struct)])
-
-        phishing_idx = _classes.index("phishing")
-        prob_phishing = _model.predict_proba(X)[0][phishing_idx]
-        risk_score = int(prob_phishing * 100)
+        import asyncio
+
+        def _infer():
+            import numpy as np
+            from scipy.sparse import csr_matrix, hstack
+            text_norm = _normalize_text(text)
+            struct = np.array([_extract_struct_features(text)])
+            X_text = _vectorizer.transform([text_norm])
+            X = hstack([X_text, csr_matrix(struct)])
+            phishing_idx = _classes.index("phishing")
+            prob = _model.predict_proba(X)[0][phishing_idx]
+            return prob, int(prob * 100)
+
+        prob_phishing, risk_score = await asyncio.to_thread(_infer)

Also worth wrapping _load_artifacts() (or at least the joblib.load calls) the same way for the cold-start case.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/service/security/naive_bayes_text_analyzer.py` around lines 90 - 132,
Offload the synchronous artifact loading and inference work in
analyze_text_with_naive_bayes to a worker thread so it does not block the
asyncio event loop. Wrap _load_artifacts for cold-start loading and move
_vectorizer.transform, feature construction, and _model.predict_proba into a
synchronous helper invoked via asyncio.to_thread (or the project’s equivalent),
while preserving the existing availability, result, and exception behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/service/scan_service.py`:
- Around line 118-129: Update the pipeline exception handler around the returned
SmishingAnalysisResponse to use RiskGrade.MEDIUM instead of RiskGrade.LOW for
hard failures, while preserving final_score=0 and the existing ERROR status and
message.

In `@tests/url/test_url_tracer.py`:
- Around line 7-30: Update the tests test_normal_url_no_redirect,
test_tinyurl_resolution, and test_broken_url_graceful_handling to mock
AsyncClient responses instead of contacting Google, TinyURL, or DNS. Configure
the mocks to represent no redirect, a resolved TinyURL destination, and the
timeout/error fallback respectively, while preserving each test’s existing
assertions; leave live checks for a separately marked integration suite.
- Around line 87-105: The direct-target SSRF tests must verify blocking occurs
before network I/O, not only by comparing the returned URL. In
test_ssrf_guard_blocks_loopback_ip_literal,
test_ssrf_guard_blocks_cloud_metadata_ip, and
test_ssrf_guard_blocks_private_lan_ip, patch httpx.AsyncClient.head and assert
the mock was never awaited after calling trace_url.

---

Duplicate comments:
In `@app/utils/url_tracker.py`:
- Around line 16-58: The SSRF validation in _is_public_host is vulnerable to DNS
rebinding because trace_url passes the hostname to httpx, which resolves it
again. Update _is_public_host and the trace_url request flow to return or retain
the validated address, then use a custom httpx transport that connects to that
pinned IP while preserving the original hostname for the Host header and TLS SNI
on every redirect hop.

---

Nitpick comments:
In `@app/service/security/naive_bayes_text_analyzer.py`:
- Around line 90-132: Offload the synchronous artifact loading and inference
work in analyze_text_with_naive_bayes to a worker thread so it does not block
the asyncio event loop. Wrap _load_artifacts for cold-start loading and move
_vectorizer.transform, feature construction, and _model.predict_proba into a
synchronous helper invoked via asyncio.to_thread (or the project’s equivalent),
while preserving the existing availability, result, and exception behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 45aad74d-2369-4b33-b72c-e82691ba1e72

📥 Commits

Reviewing files that changed from the base of the PR and between c62548f and 7715243.

📒 Files selected for processing (18)
  • SCORING_PIPELINE_CHANGES.md
  • app/dto/schemas.py
  • app/service/scan_service.py
  • app/service/security/hybrid_url_engine.py
  • app/service/security/naive_bayes_text_analyzer.py
  • app/service/security/rule_based_analyzer.py
  • app/service/security/virustotal.py
  • app/utils/scoring_engine.py
  • app/utils/url_tracker.py
  • requirements.txt
  • tests/e2e/test_analyze_endpoint.py
  • tests/security/test_hybrid_url_engine.py
  • tests/security/test_naive_bayes_text_analyzer.py
  • tests/security/test_rule_based_analyzer.py
  • tests/security/test_scoring_engine.py
  • tests/security/test_virustotal.py
  • tests/service/test_scan_service.py
  • tests/url/test_url_tracer.py
🚧 Files skipped from review as they are similar to previous changes (9)
  • app/service/security/virustotal.py
  • tests/security/test_rule_based_analyzer.py
  • tests/security/test_virustotal.py
  • app/service/security/rule_based_analyzer.py
  • tests/e2e/test_analyze_endpoint.py
  • app/dto/schemas.py
  • app/service/security/hybrid_url_engine.py
  • tests/security/test_hybrid_url_engine.py
  • app/utils/scoring_engine.py

Comment thread app/service/scan_service.py
Comment thread tests/url/test_url_tracer.py
Comment thread tests/url/test_url_tracer.py
…SSRF

_is_public_host validated a hostname's DNS resolution but discarded the
resolved IP, letting httpx re-resolve at connect time — an attacker with
a short-TTL record could pass validation then rebind to a private/metadata
IP before the actual request. Now the validated IP is pinned via a custom
httpx transport/network backend (SNI and Host header stay on the original
hostname, so TLS validation is unaffected), and the getaddrinfo call is
wrapped in a timeout to avoid hanging on unresponsive resolvers.
@kite-pp
kite-pp force-pushed the feat/19-hybrid-scoring-pipeline branch from 5bcacef to c6d2149 Compare July 25, 2026 07:09

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
app/utils/url_tracker.py (2)

164-167: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

limits is ignored when a custom transport is passed.

AsyncClient only applies limits to the transport it builds itself; here the pool limits come from _PinnedIPTransport. Drop it to avoid implying otherwise.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/utils/url_tracker.py` around lines 164 - 167, Remove the unused limits
configuration and the limits argument from the httpx.AsyncClient construction in
the URL tracking flow, since the custom _PinnedIPTransport controls connection
pooling. Keep the existing transport and redirect behavior unchanged.

113-129: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Avoid relying on httpx’s private SSL config helper.

_PinnedIPTransport.__init__ calls httpx._config.create_ssl_context and skips super().__init__(), so a minor httpx upgrade can break this transport by changing a private API. Build the SSL context explicitly instead (or forward through the public transport constructor) and consider a narrower httpx/httpcore range for the pinned connection pool.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/utils/url_tracker.py` around lines 113 - 129, Update
_PinnedIPTransport.__init__ to stop importing httpx._config.create_ssl_context
and avoid depending on that private API; construct the required SSL context
through supported public APIs or reuse the public transport initialization path
while preserving the custom _PinnedIPBackend connection pool. Ensure the pinned
pool remains compatible with the supported httpx/httpcore versions.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/utils/url_tracker.py`:
- Around line 186-189: After normalizing Location with urljoin in the
redirect-tracking flow, validate the resulting current_url scheme against an
HTTP/HTTPS allowlist before assigning or returning it. Reject non-HTTP(S)
schemes such as ftp, javascript, and data so they cannot become the final URL or
trigger the next request; use the existing redirect/error handling path where
appropriate.

---

Nitpick comments:
In `@app/utils/url_tracker.py`:
- Around line 164-167: Remove the unused limits configuration and the limits
argument from the httpx.AsyncClient construction in the URL tracking flow, since
the custom _PinnedIPTransport controls connection pooling. Keep the existing
transport and redirect behavior unchanged.
- Around line 113-129: Update _PinnedIPTransport.__init__ to stop importing
httpx._config.create_ssl_context and avoid depending on that private API;
construct the required SSL context through supported public APIs or reuse the
public transport initialization path while preserving the custom
_PinnedIPBackend connection pool. Ensure the pinned pool remains compatible with
the supported httpx/httpcore versions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 285538a5-3dcb-43ef-ad2b-de5cec8261b4

📥 Commits

Reviewing files that changed from the base of the PR and between 7715243 and 5bcacef.

📒 Files selected for processing (1)
  • app/utils/url_tracker.py

Comment thread app/utils/url_tracker.py
Comment on lines +186 to +189
if not location.startswith(("http://", "https://")):
location = urljoin(current_url, location)

current_url = location

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Add a scheme allowlist after normalization.

urljoin returns any absolute non-http(s) Location verbatim (ftp://…, javascript:…, data:…). That value becomes current_url, the next hop raises inside httpx, the generic handler breaks the loop, and the non-http URL is returned to callers as the "final" URL.

🛡️ Proposed fix
                 # 절대 URL이 아닌 모든 경우(경로만/프로토콜 상대경로 등)를 urljoin으로 정규화
                 if not location.startswith(("http://", "https://")):
                     location = urljoin(current_url, location)
 
+                # http(s) 이외의 스킴으로의 리다이렉트는 추적 대상이 아니므로 중단
+                if urlparse(location).scheme not in ("http", "https"):
+                    logger.warning(f"지원하지 않는 스킴으로의 리다이렉트 차단: {location}")
+                    break
+
                 current_url = location
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if not location.startswith(("http://", "https://")):
location = urljoin(current_url, location)
current_url = location
if not location.startswith(("http://", "https://")):
location = urljoin(current_url, location)
# http(s) 이외의 스킴으로의 리다이렉트는 추적 대상이 아니므로 중단
if urlparse(location).scheme not in ("http", "https"):
logger.warning(f"지원하지 않는 스킴으로의 리다이렉트 차단: {location}")
break
current_url = location
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/utils/url_tracker.py` around lines 186 - 189, After normalizing Location
with urljoin in the redirect-tracking flow, validate the resulting current_url
scheme against an HTTP/HTTPS allowlist before assigning or returning it. Reject
non-HTTP(S) schemes such as ftp, javascript, and data so they cannot become the
final URL or trigger the next request; use the existing redirect/error handling
path where appropriate.

kite-pp added 4 commits July 25, 2026 16:30
…nt test, fix stale test drift

- SSRF guard tests for direct blocked-IP targets now patch httpx.AsyncClient.head
  and assert it was never awaited, so a passing result actually proves the guard
  ran before any connection instead of just matching by coincidence.
- e2e analyze endpoint test mocks app.service.scan_service.trace_url instead of
  hitting google.com live, removing outbound DNS/HTTP dependency from CI; redirect
  behavior stays covered by the dedicated URL-tracer tests.
- Fixed 3 test files broken by prior refactors: gemini_text_analyzer test now
  patches the actual MOCK_ENABLED constant instead of a removed is_mock_enabled
  function, url_extractor test points at the current app.utils.url_tracker
  location, and removed parser.py + its test (dead code superseded by
  hybrid_url_engine.py, never imported anywhere, and buggy on its live path).
… into integration suite

Tests were hitting google.com, tinyurl.com, and a nonexistent domain over
real DNS/HTTP, making them flaky or impossible offline/in CI. Unit tests now
mock httpx.AsyncClient.head and _is_public_host so the redirect/DNS-failure
paths are deterministic. The same live checks move to
tests/url/test_url_tracer_integration.py under a new `integration` pytest
marker, excluded by default via pytest.ini and run explicitly with
`pytest -m integration`.
analyze_pipeline's outer exception handler returned final_score=0,
risk_grade=LOW on any unhandled failure (network error, bug, etc.) -
the same "confirmed safe" outcome the BOTH_ENGINES_UNAVAILABLE_FALLBACK_SCORE
guard was added to prevent for the narrower case of both text classifiers
being down. A consumer reading risk_grade/final_score without checking
status=="ERROR" would see an unanalyzed message as low risk. Now the
fallback forces both the grade (MEDIUM) and the score
(ScoringEngine.PIPELINE_FAILURE_FALLBACK_SCORE) together so the response
stays internally consistent instead of just swapping the enum value.
…alicious override

The GSB/VT/rule-domain confirmed-malicious override forced final_score up to
70+ but left llm_contrib/url_contrib/rules_contrib (the values fed into
contribution_breakdown) at their pre-override sum, so the breakdown could sit
tens of points below the score it's supposed to explain. Now the score gap
introduced by the override is distributed into the breakdown too - url first
(the override's usual source), spilling into rules then llm if url_contrib
is already at its schema cap - so llm+hybrid_url+rules always equals
final_score.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant