diff --git a/src/netcheck/reputation.py b/src/netcheck/reputation.py index d80824e..503bbec 100644 --- a/src/netcheck/reputation.py +++ b/src/netcheck/reputation.py @@ -169,26 +169,43 @@ def captcha_risk( ip_type: str, abuseipdb_score: int | None, ) -> tuple[str, str]: + risk, rationale, _ = _captcha_risk_full(firehol_hits, dnsbl_hits, ip_type, abuseipdb_score) + return risk, rationale + + +def _captcha_risk_full( + firehol_hits: list[str], + dnsbl_hits: list[DnsblHit], + ip_type: str, + abuseipdb_score: int | None, +) -> tuple[str, str, str]: reasons: list[str] = [] + reasons_ru: list[str] = [] risk = "low" if firehol_hits: risk = "high" reasons.append(f"listed on {', '.join(firehol_hits)}") + reasons_ru.append(f"в блоклисте {', '.join(firehol_hits)}") if dnsbl_hits: risk = "high" reasons.append(f"listed on {', '.join(h.zone for h in dnsbl_hits)}") + reasons_ru.append(f"в блоклисте {', '.join(h.zone for h in dnsbl_hits)}") if abuseipdb_score is not None and abuseipdb_score >= 50: risk = "high" reasons.append(f"AbuseIPDB confidence {abuseipdb_score}") + reasons_ru.append(f"уверенность AbuseIPDB {abuseipdb_score}") elif abuseipdb_score is not None and abuseipdb_score >= 25 and risk == "low": risk = "medium" reasons.append(f"AbuseIPDB confidence {abuseipdb_score}") + reasons_ru.append(f"уверенность AbuseIPDB {abuseipdb_score}") if risk == "low" and ip_type == "hosting": risk = "medium" reasons.append("egress looks like hosting/proxy space, which many sites challenge by default") + reasons_ru.append("исходящий адрес похож на хостинг/прокси-пространство, которое многие сайты по умолчанию проверяют капчей") if not reasons: reasons.append("no blocklist match and the address looks like ordinary end-user space") - return risk, "; ".join(reasons) + reasons_ru.append("совпадений с блоклистами нет, адрес выглядит как обычное пользовательское пространство") + return risk, "; ".join(reasons), "; ".join(reasons_ru) def build_reputation( @@ -203,7 +220,7 @@ def build_reputation( blocked = False if dnsbl_outcomes is not None: hits, blocked = summarize_dnsbl(dnsbl_outcomes) - risk, rationale = captcha_risk(firehol_hits, hits or [], ip_type, abuseipdb_score) + risk, rationale, rationale_ru = _captcha_risk_full(firehol_hits, hits or [], ip_type, abuseipdb_score) return Reputation( internetdb=internetdb, firehol_hits=firehol_hits, @@ -213,6 +230,7 @@ def build_reputation( abuseipdb_reports=abuseipdb_reports, captcha_risk=risk, rationale=rationale, + rationale_ru=rationale_ru, ) diff --git a/tests/test_reputation.py b/tests/test_reputation.py index 347b25e..16920c9 100644 --- a/tests/test_reputation.py +++ b/tests/test_reputation.py @@ -156,3 +156,46 @@ def test_build_reputation_without_dnsbl_leaves_the_field_none(api_fixture): assert rep.dnsbl_hits is None assert rep.dnsbl_query_blocked is False assert rep.captcha_risk == "high" + + +_CYRILLIC = set("абвгдеёжзийклмнопрстуфхцчшщъыьэюя") + + +def has_cyrillic(text: str) -> bool: + return any(ch in _CYRILLIC for ch in text.lower()) + + +def test_build_reputation_rationale_ru_is_populated_and_cyrillic_for_every_reason(api_fixture): + cases = [ + dict(firehol_hits=["firehol_level1"], dnsbl_outcomes=None, ip_type="residential", abuseipdb_score=None), + dict( + firehol_hits=[], + dnsbl_outcomes=[decode_dnsbl("zen.spamhaus.org", ["127.0.0.2"])], + ip_type="residential", + abuseipdb_score=None, + ), + dict(firehol_hits=[], dnsbl_outcomes=None, ip_type="residential", abuseipdb_score=90), + dict(firehol_hits=[], dnsbl_outcomes=None, ip_type="residential", abuseipdb_score=30), + dict(firehol_hits=[], dnsbl_outcomes=None, ip_type="hosting", abuseipdb_score=None), + dict(firehol_hits=[], dnsbl_outcomes=None, ip_type="residential", abuseipdb_score=None), + ] + for case in cases: + rep = build_reputation( + internetdb=normalize_internetdb(api_fixture("internetdb.json")), + firehol_hits=case["firehol_hits"], + dnsbl_outcomes=case["dnsbl_outcomes"], + ip_type=case["ip_type"], + abuseipdb_score=case["abuseipdb_score"], + abuseipdb_reports=None, + ) + assert rep.rationale + assert rep.rationale_ru + assert has_cyrillic(rep.rationale_ru) + + +def test_captcha_risk_public_signature_still_returns_a_two_tuple(): + result = captcha_risk([], [], "residential", None) + assert len(result) == 2 + risk, rationale = result + assert risk == "low" + assert rationale