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.
This commit is contained in:
87
decnet/geoip/ptr.py
Normal file
87
decnet/geoip/ptr.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Reverse DNS (PTR record) lookup for attacker IPs.
|
||||
|
||||
Colocated with ``decnet.geoip`` because the shape matches: take an IP,
|
||||
return a piece of supplementary metadata, never raise. Same operator
|
||||
posture as ``enrich_ip`` — a missing PTR must never break profile
|
||||
building.
|
||||
|
||||
The profiler calls this once per attacker IP at first sighting. Never
|
||||
re-resolves — the profiler tracks already-attempted IPs in-memory
|
||||
(``_WorkerState.ptr_attempted``) so a persistent NXDOMAIN doesn't burn
|
||||
2 seconds of tick time on every cycle.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
from typing import Optional
|
||||
|
||||
from decnet.logging import get_logger
|
||||
|
||||
log = get_logger("geoip.ptr")
|
||||
|
||||
|
||||
_DEFAULT_TIMEOUT = 2.0
|
||||
|
||||
|
||||
def _is_resolvable(ip: str) -> bool:
|
||||
"""True iff ``ip`` is a parseable public address worth querying.
|
||||
|
||||
Private / loopback / link-local / multicast / reserved addresses
|
||||
have no meaningful PTR at the public resolver level, so short-
|
||||
circuit before spending a DNS round-trip on them.
|
||||
"""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if addr.is_loopback or addr.is_private or addr.is_link_local:
|
||||
return False
|
||||
if addr.is_multicast or addr.is_reserved or addr.is_unspecified:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _blocking_lookup(ip: str) -> Optional[str]:
|
||||
"""Synchronous PTR lookup — runs in the executor thread."""
|
||||
try:
|
||||
hostname, _aliases, _addrs = socket.gethostbyaddr(ip)
|
||||
return hostname or None
|
||||
except (socket.herror, socket.gaierror, OSError):
|
||||
return None
|
||||
|
||||
|
||||
async def resolve_ptr_record(
|
||||
ip: str,
|
||||
*,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
) -> Optional[str]:
|
||||
"""Resolve *ip* to a PTR / rDNS hostname.
|
||||
|
||||
Returns the canonical hostname on success, ``None`` on any failure
|
||||
(NXDOMAIN, timeout, malformed input, env kill-switch). Never raises
|
||||
— PTR is supplementary attacker metadata; a missing lookup must not
|
||||
break profile building.
|
||||
|
||||
Honours ``DECNET_PTR_ENABLED=false`` for locked-down environments
|
||||
where egress DNS is forbidden.
|
||||
"""
|
||||
if os.environ.get("DECNET_PTR_ENABLED", "true").lower() == "false":
|
||||
return None
|
||||
if not _is_resolvable(ip):
|
||||
return None
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
loop.run_in_executor(None, _blocking_lookup, ip),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.debug("ptr: timeout resolving %s after %.1fs", ip, timeout)
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 — supplementary metadata
|
||||
log.debug("ptr: resolver crashed for %s: %s", ip, exc)
|
||||
return None
|
||||
Reference in New Issue
Block a user