Files
DECNET/tests/geoip/test_ptr.py
anti 5a34371009 feat(attackers): PTR record (reverse DNS) enrichment
Resolve each attacker IP's rDNS name once at first sighting, store on
Attacker.ptr_record, render on AttackerDetail under ORIGIN. Many
attackers run infrastructure with forgotten rDNS that instantly
identifies them once surfaced: scan-node-42.shodan.io,
shady-vps.leasecloud.net, etc.

Resolver lives in decnet/geoip/ptr.py — colocated with enrich_ip
because the shape matches (take an IP, return supplementary
metadata, never raise). Uses the OS resolver via socket.gethostbyaddr
offloaded to the default executor, wrapped with asyncio.wait_for
timeout=2s so a slow authoritative NS can't stall the profiler tick.

Profiler side: _WorkerState grows a ptr_attempted: set[str] bounding
resolution to once per worker lifetime. Cold-start batches resolve
concurrently (Semaphore(_PTR_CONCURRENCY=10)) so a backlog doesn't
serialize 2s ceilings. _build_record gains a keyword-only ptr_record
parameter that, when _UNSET, omits the key from the record dict —
upsert_attacker's attribute-merge loop then preserves whatever's
stored on the row. Explicit None is a "fresh failed attempt" signal
and gets written through.

Env kill-switch DECNET_PTR_ENABLED=false for locked-down deploys
where egress DNS is forbidden. Private / loopback / link-local /
multicast / reserved addresses short-circuit before any DNS call.
IPv6 reverse DNS works transparently through the stdlib resolver.

Schema change — run once on upgrade:

  ALTER TABLE attackers
    ADD COLUMN ptr_record VARCHAR(256) NULL DEFAULT NULL;

Or drop-and-recreate on dev boxes (db-reset's SQLModel.metadata-driven
table discovery now picks it up automatically since ba155b7).

tests/conftest.py disables DECNET_PTR_ENABLED globally for the same
reason it disables DECNET_GEOIP_ENABLED — unit tests must never hit
the network. tests/geoip/test_ptr.py re-enables explicitly via an
autouse fixture.
2026-04-24 17:26:40 -04:00

120 lines
3.6 KiB
Python

"""Unit tests for decnet.geoip.ptr — reverse-DNS resolver."""
from __future__ import annotations
import asyncio
import socket
from unittest.mock import patch
import pytest
from decnet.geoip.ptr import _is_resolvable, resolve_ptr_record
@pytest.fixture(autouse=True)
def _enable_ptr(monkeypatch):
"""This module covers the resolver directly — re-enable the env
switch that tests/conftest.py disables globally."""
monkeypatch.setenv("DECNET_PTR_ENABLED", "true")
# ─── pure predicate ─────────────────────────────────────────────────────────
@pytest.mark.parametrize("ip", [
"127.0.0.1",
"10.0.0.1",
"192.168.1.5",
"172.16.0.1",
"169.254.1.1", # link-local
"224.0.0.1", # multicast
"::1",
"fe80::1", # IPv6 link-local
"not-an-ip",
"",
])
def test_not_resolvable(ip: str):
assert _is_resolvable(ip) is False
@pytest.mark.parametrize("ip", [
"8.8.8.8",
"1.1.1.1",
"2606:4700:4700::1111",
])
def test_resolvable_public(ip: str):
assert _is_resolvable(ip) is True
# ─── resolver ───────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_resolves_public_ip():
with patch(
"decnet.geoip.ptr.socket.gethostbyaddr",
return_value=("dns.google", [], ["8.8.8.8"]),
):
name = await resolve_ptr_record("8.8.8.8")
assert name == "dns.google"
@pytest.mark.asyncio
async def test_private_ip_short_circuits():
"""Private IPs never touch the resolver."""
with patch("decnet.geoip.ptr.socket.gethostbyaddr") as mock_lookup:
assert await resolve_ptr_record("127.0.0.1") is None
assert await resolve_ptr_record("10.0.0.1") is None
assert await resolve_ptr_record("::1") is None
assert mock_lookup.call_count == 0
@pytest.mark.asyncio
async def test_gethostbyaddr_herror_returns_none():
with patch(
"decnet.geoip.ptr.socket.gethostbyaddr",
side_effect=socket.herror("no rDNS"),
):
assert await resolve_ptr_record("8.8.8.8") is None
@pytest.mark.asyncio
async def test_gethostbyaddr_gaierror_returns_none():
with patch(
"decnet.geoip.ptr.socket.gethostbyaddr",
side_effect=socket.gaierror("dns broken"),
):
assert await resolve_ptr_record("8.8.8.8") is None
@pytest.mark.asyncio
async def test_timeout_returns_none():
"""A slow resolver should not block the caller past timeout."""
def slow(ip: str): # noqa: ARG001
import time
time.sleep(3.0)
return ("slow.example", [], [])
with patch("decnet.geoip.ptr.socket.gethostbyaddr", side_effect=slow):
# Tight timeout — must return quickly.
result = await asyncio.wait_for(
resolve_ptr_record("8.8.8.8", timeout=0.1),
timeout=1.0,
)
assert result is None
@pytest.mark.asyncio
async def test_env_disabled(monkeypatch):
monkeypatch.setenv("DECNET_PTR_ENABLED", "false")
with patch("decnet.geoip.ptr.socket.gethostbyaddr") as mock_lookup:
assert await resolve_ptr_record("8.8.8.8") is None
assert mock_lookup.call_count == 0
@pytest.mark.asyncio
async def test_empty_hostname_returned_as_none():
"""gethostbyaddr can return '' on some platforms; normalize to None."""
with patch(
"decnet.geoip.ptr.socket.gethostbyaddr",
return_value=("", [], ["8.8.8.8"]),
):
assert await resolve_ptr_record("8.8.8.8") is None