mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-27 00:02:22 +02:00
* Add exact PTR queries to DNS resolver vantages * Enforce DNS query budgets across CNAME chains * Harden DNS query budget contracts
337 lines
12 KiB
Python
337 lines
12 KiB
Python
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',)
|