from __future__ import annotations from types import SimpleNamespace from typing import TYPE_CHECKING import pytest from theHarvester.lib.dns_consensus import ( Addressability, AioDNSResolverVantage, DNSQueryBudget, DNSResponse, ResolverVantage, validate_dns_candidates, ) if TYPE_CHECKING: from collections.abc import Callable class RuleResolver: def __init__(self, name: str, query: Callable[[str], DNSResponse]) -> None: self.name = name self._query = query async def query(self, hostname: str) -> DNSResponse: return self._query(hostname) if TYPE_CHECKING: legacy_resolver: ResolverVantage = RuleResolver('resolver', lambda _hostname: DNSResponse()) @pytest.mark.asyncio async def test_two_resolver_vantages_establish_current_addressability() -> None: candidate = 'api.example.com' resolvers = ( RuleResolver( 'resolver-a', lambda hostname: DNSResponse(ipv4=('192.0.2.10',)) if hostname == candidate else DNSResponse(rcode='NXDOMAIN'), ), RuleResolver( 'resolver-b', lambda hostname: DNSResponse(ipv6=('2001:db8::10',)) if hostname == candidate else DNSResponse(rcode='NXDOMAIN'), ), RuleResolver('resolver-c', lambda _hostname: DNSResponse(rcode='NXDOMAIN')), ) (result,) = await validate_dns_candidates('example.com', (candidate,), resolvers) assert result.hostname == candidate assert result.addressability is Addressability.CURRENT assert result.records.ipv4 == ('192.0.2.10',) assert result.records.ipv6 == ('2001:db8::10',) candidate_evidence = [item for item in result.validations if item.query_name == candidate] assert [item.resolver for item in candidate_evidence] == ['resolver-a', 'resolver-b', 'resolver-c'] @pytest.mark.asyncio async def test_closest_encloser_wildcard_remains_secondary_evidence() -> None: candidate = 'api.dev.example.com' def nested_wildcard(hostname: str) -> DNSResponse: if hostname == candidate or hostname.endswith('.dev.example.com'): return DNSResponse(ipv4=('192.0.2.20',)) return DNSResponse(rcode='NXDOMAIN') resolvers = tuple(RuleResolver(f'resolver-{index}', nested_wildcard) for index in range(3)) (result,) = await validate_dns_candidates('example.com', (candidate,), resolvers) assert result.addressability is Addressability.WILDCARD_INDISTINGUISHABLE controls = [item for item in result.validations if item.wildcard_depth is not None] assert {item.wildcard_depth for item in controls} == {'example.com', 'dev.example.com'} assert len(controls) == 18 @pytest.mark.asyncio async def test_distinct_exact_answer_is_not_confused_with_wildcard_distribution() -> None: candidate = 'api.dev.example.com' def distinct_exact_answer(hostname: str) -> DNSResponse: if hostname == candidate: return DNSResponse(ipv4=('192.0.2.10',)) if hostname.endswith('.dev.example.com'): return DNSResponse(ipv4=('192.0.2.99',)) return DNSResponse(rcode='NXDOMAIN') resolvers = tuple(RuleResolver(f'resolver-{index}', distinct_exact_answer) for index in range(3)) (result,) = await validate_dns_candidates('example.com', (candidate,), resolvers) assert result.addressability is Addressability.CURRENT @pytest.mark.asyncio async def test_exhausted_budget_cannot_classify_incomplete_validation_as_current() -> None: candidate = 'api.example.com' budget = DNSQueryBudget(3) class BudgetedResolver: def __init__(self, name: str) -> None: self.name = name async def query(self, hostname: str, query_budget: DNSQueryBudget | None = None) -> DNSResponse: if query_budget is not None and not query_budget.consume(1): return DNSResponse(error='query-limit') return DNSResponse(ipv4=('192.0.2.10',)) if hostname == candidate else DNSResponse(rcode='NXDOMAIN') resolvers = tuple(BudgetedResolver(f'resolver-{index}') for index in range(3)) (result,) = await validate_dns_candidates('example.com', (candidate,), resolvers, budget=budget) assert budget.blocked assert result.addressability is Addressability.RESOLVER_DISPUTED @pytest.mark.asyncio async def test_aiodns_vantage_follows_a_bounded_cname_chain(monkeypatch: pytest.MonkeyPatch) -> None: from theHarvester.lib import dns_consensus aliases = { 'alias.example.com': 'Edge.Example.COM.', 'edge.example.com': 'Origin.Example.COM.', } closed: list[bool] = [] class FakeDNSResolver: def __init__(self, *, nameservers: list[str]) -> None: assert nameservers == ['192.0.2.53'] async def query_dns(self, hostname: str, record_type: str): if record_type == 'CNAME' and hostname in aliases: return SimpleNamespace(answer=[SimpleNamespace(data=SimpleNamespace(cname=aliases[hostname]))]) if record_type == 'A' and hostname == 'origin.example.com': return SimpleNamespace(answer=[SimpleNamespace(data=SimpleNamespace(addr='192.0.2.10'))]) raise dns_consensus.aiodns.error.DNSError(dns_consensus.aiodns.error.ARES_ENODATA, 'no data') async def close(self) -> None: closed.append(True) monkeypatch.setattr(dns_consensus.aiodns, 'DNSResolver', FakeDNSResolver) resolver = AioDNSResolverVantage('192.0.2.53', 'example.com') response = await resolver.query('alias.example.com') await resolver.close() assert response.ipv4 == ('192.0.2.10',) assert response.cnames == ('edge.example.com', 'origin.example.com') assert response.error is None assert closed == [True] @pytest.mark.asyncio async def test_aiodns_vantage_stops_before_exceeding_cname_query_budget(monkeypatch: pytest.MonkeyPatch) -> None: from theHarvester.lib import dns_consensus queries: list[tuple[str, str]] = [] class FakeDNSResolver: def __init__(self, *, nameservers: list[str]) -> None: assert nameservers == ['192.0.2.53'] async def query_dns(self, hostname: str, record_type: str): queries.append((hostname, record_type)) if record_type == 'CNAME': return SimpleNamespace(answer=[SimpleNamespace(data=SimpleNamespace(cname='edge.example.com'))]) raise dns_consensus.aiodns.error.DNSError(dns_consensus.aiodns.error.ARES_ENODATA, 'no data') async def close(self) -> None: pass monkeypatch.setattr(dns_consensus.aiodns, 'DNSResolver', FakeDNSResolver) resolver = AioDNSResolverVantage('192.0.2.53', 'example.com') budget = DNSQueryBudget(3) response = await resolver.query('alias.example.com', budget) assert queries == [ ('alias.example.com', 'A'), ('alias.example.com', 'AAAA'), ('alias.example.com', 'CNAME'), ] assert response.cnames == ('edge.example.com',) assert response.error == 'query-limit' assert budget.used == 3 assert budget.blocked @pytest.mark.asyncio async def test_aiodns_vantage_does_not_follow_a_cname_outside_scope(monkeypatch: pytest.MonkeyPatch) -> None: from theHarvester.lib import dns_consensus queries: list[str] = [] class FakeDNSResolver: def __init__(self, *, nameservers: list[str]) -> None: assert nameservers == ['192.0.2.53'] async def query_dns(self, hostname: str, record_type: str): queries.append(hostname) if record_type == 'CNAME' and hostname == 'alias.example.com': return SimpleNamespace(answer=[SimpleNamespace(data=SimpleNamespace(cname='external.example.net.'))]) raise dns_consensus.aiodns.error.DNSError(dns_consensus.aiodns.error.ARES_ENODATA, 'no data') async def close(self) -> None: pass monkeypatch.setattr(dns_consensus.aiodns, 'DNSResolver', FakeDNSResolver) resolver = AioDNSResolverVantage('192.0.2.53', 'example.com') response = await resolver.query('alias.example.com') assert response.cnames == ('external.example.net',) assert set(queries) == {'alias.example.com'} @pytest.mark.asyncio async def test_aiodns_vantage_returns_normalized_ptr_evidence(monkeypatch: pytest.MonkeyPatch) -> None: from theHarvester.lib import dns_consensus queries: list[tuple[str, str]] = [] class FakeDNSResolver: def __init__(self, *, nameservers: list[str]) -> None: assert nameservers == ['192.0.2.53'] async def query_dns(self, hostname: str, record_type: str): queries.append((hostname, record_type)) return SimpleNamespace( answer=[ SimpleNamespace(data=SimpleNamespace(dname='Mail.Example.COM.')), SimpleNamespace(data=SimpleNamespace(dname='mail.example.com')), ] ) async def close(self) -> None: pass monkeypatch.setattr(dns_consensus.aiodns, 'DNSResolver', FakeDNSResolver) resolver = AioDNSResolverVantage('192.0.2.53', 'example.com') ptrs = await resolver.query_ptr('192.0.2.10') assert queries == [('10.2.0.192.in-addr.arpa', 'PTR')] assert ptrs == ('mail.example.com',) @pytest.mark.asyncio async def test_invalid_ptr_address_does_not_consume_query_budget(monkeypatch: pytest.MonkeyPatch) -> None: from theHarvester.lib import dns_consensus class FakeDNSResolver: def __init__(self, *, nameservers: list[str]) -> None: assert nameservers == ['192.0.2.53'] async def query_dns(self, _hostname: str, _record_type: str): pytest.fail('invalid input must not issue a DNS query') async def close(self) -> None: pass monkeypatch.setattr(dns_consensus.aiodns, 'DNSResolver', FakeDNSResolver) resolver = AioDNSResolverVantage('192.0.2.53', 'example.com') budget = DNSQueryBudget(1) assert await resolver.query_ptr('not-an-ip', budget) == () assert budget.used == 0 assert not budget.blocked @pytest.mark.asyncio async def test_empty_target_is_rejected_before_any_dns_query() -> None: queries: list[str] = [] def query(hostname: str) -> DNSResponse: queries.append(hostname) return DNSResponse(rcode='NXDOMAIN') resolvers = tuple(RuleResolver(f'resolver-{index}', query) for index in range(3)) with pytest.raises(ValueError, match='target must not be empty'): await validate_dns_candidates('', ('api.example.com',), resolvers) assert queries == [] @pytest.mark.asyncio async def test_resolver_vantages_must_be_canonically_distinct() -> None: resolvers = ( RuleResolver('2001:db8::1', lambda _hostname: DNSResponse(rcode='NXDOMAIN')), RuleResolver('2001:0db8:0:0:0:0:0:1', lambda _hostname: DNSResponse(rcode='NXDOMAIN')), RuleResolver('192.0.2.53', lambda _hostname: DNSResponse(rcode='NXDOMAIN')), ) with pytest.raises(ValueError, match='three distinct resolver vantages'): await validate_dns_candidates('example.com', ('api.example.com',), resolvers) @pytest.mark.asyncio async def test_candidates_are_normalized_scoped_and_deduplicated_before_queries() -> None: queries: list[str] = [] def query(hostname: str) -> DNSResponse: queries.append(hostname) return DNSResponse(rcode='NXDOMAIN') resolvers = tuple(RuleResolver(f'resolver-{index}', query) for index in range(3)) results = await validate_dns_candidates( 'Example.COM.', ('API.Example.COM.', 'api.example.com', 'outside.example.net'), resolvers, ) assert [result.hostname for result in results] == ['api.example.com'] assert queries.count('api.example.com') == 3 @pytest.mark.asyncio async def test_dangling_cname_is_not_currently_addressable() -> None: candidate = 'alias.example.com' resolvers = tuple( RuleResolver( f'resolver-{index}', lambda hostname: ( DNSResponse(cnames=('missing.vendor.test',)) if hostname == candidate else DNSResponse(rcode='NXDOMAIN') ), ) for index in range(3) ) (result,) = await validate_dns_candidates('example.com', (candidate,), resolvers) assert result.addressability is Addressability.NOT_CURRENT assert result.records.cnames == ('missing.vendor.test',)