feat: add rDNS hostname, SPF fix hints and auth tooltips to domain sources view
- dns_resolver.py: add _ip_to_arpa_name() helper and lookup_ptr() to BaseDNSProvider (no-op default), SystemDNSProvider (dnspython PTR), and CloudflareDNSProvider (DoH PTR type=12) - domains.py: extend SourceEntry with hostname + spf_fix_hint; update get_domain_sources to run async PTR lookups and generate ip4:/ip6: SPF mechanism hints for failing IPs - domain_details.html: show rDNS hostname below IP in sources table; add DaisyUI tooltip explaining each auth result; add "Fix SPF" popover with copy-paste mechanism for IPs that fail SPF - tests: 15 new tests covering _ip_to_arpa_name, SystemDNSProvider/ CloudflareDNSProvider PTR lookup, and sources endpoint hostname + fix-hint fields Agent-Logs-Url: https://github.com/christianlouis/dmarq/sessions/9eaa7749-047c-46bd-8bc0-2851ea02ffe4 Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -250,3 +250,94 @@ def test_summary_dns_failure_defaults_false(client: TestClient):
|
||||
assert domain["dmarc_status"] is False
|
||||
assert domain["spf_status"] is False
|
||||
assert domain["dkim_status"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/domains/{domain_id}/sources (PTR + fix hints)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A failing-source report used for sources tests
|
||||
FAILING_SOURCE_REPORT = {
|
||||
"domain": DOMAIN,
|
||||
"report_id": "fail-src-001",
|
||||
"org_name": "Fail Org",
|
||||
"policy": {"p": "reject", "sp": "", "pct": "100"},
|
||||
"records": [
|
||||
{
|
||||
"source_ip": "10.0.0.1",
|
||||
"count": 3,
|
||||
"disposition": "reject",
|
||||
"dkim_result": "fail",
|
||||
"spf_result": "fail",
|
||||
"dkim": [],
|
||||
"spf": [],
|
||||
}
|
||||
],
|
||||
"summary": {"total_count": 3, "passed_count": 0, "failed_count": 3, "pass_rate": 0.0},
|
||||
}
|
||||
|
||||
|
||||
def _mock_provider(hostname=None):
|
||||
"""Return a context manager that patches get_default_provider with a PTR mock."""
|
||||
mock_prov = AsyncMock()
|
||||
mock_prov.check_domain = AsyncMock(return_value=MOCK_DNS_RESULT)
|
||||
mock_prov.lookup_ptr = AsyncMock(return_value=hostname)
|
||||
return patch(
|
||||
"app.api.api_v1.endpoints.domains.get_default_provider",
|
||||
return_value=mock_prov,
|
||||
)
|
||||
|
||||
|
||||
def test_sources_endpoint_includes_hostname(client: TestClient):
|
||||
"""The /sources endpoint should return the rDNS hostname when available."""
|
||||
store = ReportStore.get_instance()
|
||||
store.add_report(FAILING_SOURCE_REPORT)
|
||||
|
||||
with _mock_provider(hostname="mail.example.com"):
|
||||
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
|
||||
|
||||
assert response.status_code == 200
|
||||
sources = response.json()["sources"]
|
||||
# Find the failing source
|
||||
failing = next((s for s in sources if s["ip"] == "10.0.0.1"), None)
|
||||
assert failing is not None
|
||||
assert failing["hostname"] == "mail.example.com"
|
||||
|
||||
|
||||
def test_sources_endpoint_hostname_none_when_no_ptr(client: TestClient):
|
||||
"""The /sources endpoint should return null hostname when no PTR record exists."""
|
||||
with _mock_provider(hostname=None):
|
||||
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
|
||||
|
||||
assert response.status_code == 200
|
||||
sources = response.json()["sources"]
|
||||
for source in sources:
|
||||
# hostname may be null; it must not crash
|
||||
assert "hostname" in source
|
||||
|
||||
|
||||
def test_sources_endpoint_spf_fix_hint_for_failing_ip(client: TestClient):
|
||||
"""A source with spf=fail should receive an spf_fix_hint containing its IP."""
|
||||
store = ReportStore.get_instance()
|
||||
store.add_report(FAILING_SOURCE_REPORT)
|
||||
|
||||
with _mock_provider():
|
||||
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
|
||||
|
||||
assert response.status_code == 200
|
||||
sources = response.json()["sources"]
|
||||
failing = next((s for s in sources if s["ip"] == "10.0.0.1"), None)
|
||||
assert failing is not None
|
||||
assert failing["spf_fix_hint"] == "ip4:10.0.0.1"
|
||||
|
||||
|
||||
def test_sources_endpoint_no_fix_hint_when_spf_passes(client: TestClient):
|
||||
"""A source with spf=pass should not receive an spf_fix_hint."""
|
||||
with _mock_provider():
|
||||
response = client.get(f"/api/v1/domains/{DOMAIN}/sources")
|
||||
|
||||
assert response.status_code == 200
|
||||
sources = response.json()["sources"]
|
||||
passing = next((s for s in sources if s["ip"] == "1.2.3.4"), None)
|
||||
if passing is not None:
|
||||
assert passing["spf_fix_hint"] is None
|
||||
|
||||
@@ -267,3 +267,147 @@ async def test_cloudflare_provider_raises_on_http_error():
|
||||
def test_get_default_provider_returns_system():
|
||||
provider = get_default_provider()
|
||||
assert isinstance(provider, SystemDNSProvider)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ip_to_arpa_name helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_ip_to_arpa_name_ipv4():
|
||||
from app.services.dns_resolver import _ip_to_arpa_name
|
||||
|
||||
assert _ip_to_arpa_name("1.2.3.4") == "4.3.2.1.in-addr.arpa"
|
||||
|
||||
|
||||
def test_ip_to_arpa_name_ipv4_leading_zero_safe():
|
||||
from app.services.dns_resolver import _ip_to_arpa_name
|
||||
|
||||
assert _ip_to_arpa_name("192.168.1.100") == "100.1.168.192.in-addr.arpa"
|
||||
|
||||
|
||||
def test_ip_to_arpa_name_ipv6():
|
||||
from app.services.dns_resolver import _ip_to_arpa_name
|
||||
|
||||
# 2001:db8::1 expanded → 20010db8000000000000000000000001
|
||||
name = _ip_to_arpa_name("2001:db8::1")
|
||||
assert name.endswith(".ip6.arpa")
|
||||
|
||||
|
||||
def test_ip_to_arpa_name_invalid_raises():
|
||||
from app.services.dns_resolver import _ip_to_arpa_name
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
_ip_to_arpa_name("not-an-ip")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BaseDNSProvider.lookup_ptr (default returns None)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_provider_lookup_ptr_returns_none():
|
||||
"""FakeDNSProvider only implements lookup_txt; lookup_ptr must return None."""
|
||||
provider = FakeDNSProvider({})
|
||||
result = await provider.lookup_ptr("1.2.3.4")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SystemDNSProvider.lookup_ptr
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_provider_lookup_ptr_returns_hostname():
|
||||
"""SystemDNSProvider.lookup_ptr should decode the first PTR rdata."""
|
||||
|
||||
class FakePTRRdata:
|
||||
def __str__(self):
|
||||
return "mail.example.com."
|
||||
|
||||
class FakePTRAnswers:
|
||||
def __iter__(self):
|
||||
return iter([FakePTRRdata()])
|
||||
|
||||
with patch("dns.asyncresolver.resolve", new=AsyncMock(return_value=FakePTRAnswers())):
|
||||
provider = SystemDNSProvider()
|
||||
hostname = await provider.lookup_ptr("1.2.3.4")
|
||||
|
||||
# Trailing dot should be stripped
|
||||
assert hostname == "mail.example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_provider_lookup_ptr_returns_none_on_nxdomain():
|
||||
import dns.exception # type: ignore[import]
|
||||
|
||||
with patch(
|
||||
"dns.asyncresolver.resolve",
|
||||
new=AsyncMock(side_effect=dns.exception.DNSException("NXDOMAIN")),
|
||||
):
|
||||
provider = SystemDNSProvider()
|
||||
hostname = await provider.lookup_ptr("1.2.3.4")
|
||||
|
||||
assert hostname is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_system_provider_lookup_ptr_returns_none_for_invalid_ip():
|
||||
provider = SystemDNSProvider()
|
||||
result = await provider.lookup_ptr("not-an-ip")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CloudflareDNSProvider.lookup_ptr
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloudflare_provider_lookup_ptr_returns_hostname():
|
||||
"""CloudflareDNSProvider.lookup_ptr should extract the PTR name from DoH JSON."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
fake_response_data = {
|
||||
"Answer": [
|
||||
{"type": 12, "data": "mail.example.com."},
|
||||
{"type": 1, "data": "93.184.216.34"}, # A record — should be ignored
|
||||
]
|
||||
}
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json = lambda: fake_response_data
|
||||
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=mock_response)):
|
||||
provider = CloudflareDNSProvider()
|
||||
hostname = await provider.lookup_ptr("1.2.3.4")
|
||||
|
||||
assert hostname == "mail.example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloudflare_provider_lookup_ptr_returns_none_when_no_ptr():
|
||||
"""CloudflareDNSProvider.lookup_ptr returns None when no PTR answer exists."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
fake_response_data = {"Answer": []}
|
||||
|
||||
mock_response = AsyncMock()
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_response.json = lambda: fake_response_data
|
||||
|
||||
with patch("httpx.AsyncClient.get", new=AsyncMock(return_value=mock_response)):
|
||||
provider = CloudflareDNSProvider()
|
||||
hostname = await provider.lookup_ptr("1.2.3.4")
|
||||
|
||||
assert hostname is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cloudflare_provider_lookup_ptr_returns_none_for_invalid_ip():
|
||||
provider = CloudflareDNSProvider()
|
||||
result = await provider.lookup_ptr("not-an-ip")
|
||||
assert result is None
|
||||
|
||||
Reference in New Issue
Block a user