Files
theHarvester/tests/lib/test_hostchecker.py
MattandGitHub 1a9b114a5d fix: complete DNS enumeration by default (#2542)
* fix: complete DNS enumeration by default

* test: avoid URL substring sanitizer alert
2026-08-14 22:54:25 -04:00

765 lines
28 KiB
Python

import asyncio
import tracemalloc
from types import SimpleNamespace
from typing import ClassVar
import pytest
from theHarvester.discovery import dnssearch
from theHarvester.lib import hostchecker
@pytest.mark.asyncio
async def test_check_deduplicates_candidates_and_bounds_active_hostnames(monkeypatch: pytest.MonkeyPatch) -> None:
query_counts: dict[tuple[str, str], int] = {}
active_queries: dict[str, int] = {}
peak_active_hosts = 0
closed = False
class FakeResolver:
async def query_dns(self, host: str, record_type: str):
nonlocal peak_active_hosts
query_counts[host, record_type] = query_counts.get((host, record_type), 0) + 1
active_queries[host] = active_queries.get(host, 0) + 1
peak_active_hosts = max(peak_active_hosts, len(active_queries))
await asyncio.sleep(0)
active_queries[host] -= 1
if not active_queries[host]:
del active_queries[host]
if record_type == 'A':
record = SimpleNamespace(data=SimpleNamespace(addr='192.0.2.10'))
return SimpleNamespace(answer=[record])
raise hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENODATA, 'no data')
async def close(self) -> None:
nonlocal closed
closed = True
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
candidates = [f'host-{index}.example.com' for index in range(5)]
checker = hostchecker.Checker(
[*candidates, 'HOST-0.EXAMPLE.COM.'],
nameservers=[],
concurrency=2,
)
resolved, hosts, addresses = await checker.check()
assert hosts == candidates
assert resolved == [f'{host}:192.0.2.10' for host in candidates]
assert addresses == ['192.0.2.10']
assert query_counts == {(host, record_type): 1 for host in candidates for record_type in ('A', 'AAAA', 'CNAME')}
assert peak_active_hosts == checker.concurrency
assert closed
@pytest.mark.asyncio
async def test_check_default_limits_process_more_than_former_query_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def query_dns(self, host: str, record_type: str):
if record_type == 'A':
return SimpleNamespace(answer=[SimpleNamespace(data=SimpleNamespace(addr='192.0.2.10'))])
raise hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENODATA, 'no data')
async def close(self) -> None:
return None
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker([f'host-{index}.example.com' for index in range(1_001)], nameservers=[])
_resolved, hosts, _addresses = await checker.check()
assert len(hosts) == checker.completed_count == 1_001
assert checker.request_count == 3_003
assert checker.stop_reason is None
@pytest.mark.parametrize(
('option', 'value', 'message'),
[
('concurrency', True, 'DNS concurrency must be a positive integer'),
('concurrency', 0, 'DNS concurrency must be a positive integer'),
('request_limit', False, 'DNS request limit must be a positive integer'),
('request_limit', 1.5, 'DNS request limit must be a positive integer'),
('runtime_seconds', True, 'DNS runtime must be a positive finite number'),
('runtime_seconds', float('inf'), 'DNS runtime must be a positive finite number'),
],
)
def test_checker_rejects_invalid_finite_limits(option: str, value: object, message: str) -> None:
with pytest.raises(ValueError, match=message):
hostchecker.Checker(['one.example.com'], [], **{option: value})
@pytest.mark.asyncio
async def test_check_stops_at_record_query_budget_and_retains_completed_results(
monkeypatch: pytest.MonkeyPatch,
) -> None:
queried: list[tuple[str, str]] = []
class FakeResolver:
async def query_dns(self, host: str, record_type: str):
queried.append((host, record_type))
if record_type == 'A':
record = SimpleNamespace(data=SimpleNamespace(addr='192.0.2.10'))
return SimpleNamespace(answer=[record])
raise hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENODATA, 'no data')
async def close(self) -> None:
return None
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(
['one.example.com', 'two.example.com'],
nameservers=[],
concurrency=1,
request_limit=3,
)
assert await checker.check() == (
['one.example.com:192.0.2.10'],
['one.example.com'],
['192.0.2.10'],
)
assert queried == [
('one.example.com', 'A'),
('one.example.com', 'AAAA'),
('one.example.com', 'CNAME'),
]
assert checker.request_count == 3
assert checker.completed_count == 1
assert checker.stop_reason == 'query-limit'
@pytest.mark.asyncio
async def test_check_runtime_limit_retains_completed_results_and_closes_resolver(
monkeypatch: pytest.MonkeyPatch,
) -> None:
closed = False
class FakeResolver:
async def query_dns(self, host: str, record_type: str):
if host == 'two.example.com':
await asyncio.Event().wait()
if record_type == 'A':
record = SimpleNamespace(data=SimpleNamespace(addr='192.0.2.10'))
return SimpleNamespace(answer=[record])
raise hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENODATA, 'no data')
async def close(self) -> None:
nonlocal closed
closed = True
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(
['one.example.com', 'two.example.com'],
nameservers=[],
concurrency=1,
runtime_seconds=0.01,
)
assert await checker.check() == (
['one.example.com:192.0.2.10'],
['one.example.com'],
['192.0.2.10'],
)
assert checker.request_count == 6
assert checker.completed_count == 1
assert checker.stop_reason == 'runtime-limit'
assert closed
@pytest.mark.asyncio
async def test_check_cancellation_closes_resolver_before_propagating(monkeypatch: pytest.MonkeyPatch) -> None:
started = asyncio.Event()
closed = False
class FakeResolver:
async def query_dns(self, _host: str, _record_type: str):
started.set()
await asyncio.Event().wait()
async def close(self) -> None:
nonlocal closed
closed = True
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['one.example.com'], nameservers=[])
task = asyncio.create_task(checker.check())
await started.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
assert closed
@pytest.mark.asyncio
async def test_check_repeated_cancellation_finishes_close_and_preserves_first_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
query_started = asyncio.Event()
close_started = asyncio.Event()
close_release = asyncio.Event()
closed = asyncio.Event()
class FakeResolver:
async def query_dns(self, _host: str, _record_type: str):
query_started.set()
await asyncio.Event().wait()
async def close(self) -> None:
close_started.set()
await close_release.wait()
closed.set()
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['one.example.com'], nameservers=[])
task = asyncio.create_task(checker.check())
await query_started.wait()
task.cancel('dns-query-cancelled')
await close_started.wait()
task.cancel('operator-stop-again')
await asyncio.sleep(0)
close_release.set()
with pytest.raises(asyncio.CancelledError) as raised:
await task
assert raised.value.args == ('dns-query-cancelled',)
assert closed.is_set()
@pytest.mark.asyncio
async def test_check_retains_a_record_and_excludes_missing_candidate(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def query_dns(self, host: str, record_type: str):
if host == 'missing.example.com' or record_type != 'A':
raise OSError('not found')
record = SimpleNamespace(data=SimpleNamespace(addr='192.0.2.10'))
return SimpleNamespace(answer=[record])
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['found.example.com', 'missing.example.com'], nameservers=[])
resolved, hosts, addresses = await checker.check()
assert resolved == ['found.example.com:192.0.2.10']
assert hosts == ['found.example.com']
assert addresses == ['192.0.2.10']
assert checker.records['found.example.com'].ipv4 == ('192.0.2.10',)
@pytest.mark.asyncio
async def test_check_retains_aaaa_only_candidate(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def query_dns(self, _host: str, record_type: str):
if record_type == 'AAAA':
record = SimpleNamespace(data=SimpleNamespace(addr='2001:0db8::10'))
return SimpleNamespace(answer=[record])
raise OSError('no data')
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['ipv6.example.com'], nameservers=[])
resolved, hosts, addresses = await checker.check()
assert resolved == ['ipv6.example.com:2001:db8::10']
assert hosts == ['ipv6.example.com']
assert addresses == ['2001:db8::10']
assert checker.records['ipv6.example.com'].ipv6 == ('2001:db8::10',)
@pytest.mark.asyncio
async def test_check_retains_cname_only_candidate(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def query_dns(self, _host: str, record_type: str):
if record_type == 'CNAME':
record = SimpleNamespace(data=SimpleNamespace(cname='Target.Example.NET.'))
return SimpleNamespace(answer=[record])
raise OSError('no data')
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['alias.example.com'], nameservers=[])
resolved, hosts, addresses = await checker.check()
assert resolved == ['alias.example.com']
assert hosts == ['alias.example.com']
assert addresses == []
assert checker.records['alias.example.com'].cnames == ('target.example.net',)
@pytest.mark.asyncio
async def test_dns_force_preserves_legacy_result_and_typed_records(monkeypatch: pytest.MonkeyPatch) -> None:
records = {'www.example.com': hostchecker.HostDnsRecords(ipv4=('192.0.2.10',))}
class FakeChecker:
def __init__(self, _hosts: list[str], nameservers: list[str], **_kwargs: object) -> None:
assert nameservers == ['192.0.2.53']
self.records = records
self.query_error_count = 2
self.query_error_types = {'TimeoutError'}
async def check(self) -> tuple[list[str], list[str], list[str]]:
return ['www.example.com:192.0.2.10'], ['www.example.com'], ['192.0.2.10']
monkeypatch.setattr(dnssearch.hostchecker, 'Checker', FakeChecker)
dns_force = dnssearch.DnsForce('example.com', ['192.0.2.53'])
dns_force.list = ['www.example.com']
result = await dns_force.run()
assert result == (['www.example.com:192.0.2.10'], ['www.example.com'], ['192.0.2.10'])
assert dns_force.records is records
assert dns_force.query_error_count == 2
assert dns_force.query_error_types == {'TimeoutError'}
@pytest.mark.asyncio
async def test_dns_force_admits_every_candidate_without_source_validation_limits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
admitted: list[str] = []
class FakeChecker:
records: ClassVar[dict[str, hostchecker.HostDnsRecords]] = {}
query_error_count = 0
query_error_types: ClassVar[set[str]] = set()
def __init__(
self,
hosts: list[str],
nameservers: list[str],
*,
concurrency: int,
request_limit: int | None,
runtime_seconds: float | None,
) -> None:
assert nameservers == ['192.0.2.53']
assert concurrency == 50
assert request_limit is None
assert runtime_seconds is None
self.completed_count = len(hosts)
self.stop_reason = None
admitted.extend(hosts)
async def check(self) -> tuple[list[str], list[str], list[str]]:
return [], [], []
monkeypatch.setattr(dnssearch.hostchecker, 'Checker', FakeChecker)
dns_force = dnssearch.DnsForce('example.com', ['192.0.2.53'])
assert len(dns_force.list) > 1_000
assert await dns_force.run() == ([], [], [])
assert admitted == dns_force.list
assert dns_force.completed_count == len(dns_force.list)
assert dns_force.stop_reason is None
def test_dns_force_preserves_selected_www_target() -> None:
dns_force = dnssearch.DnsForce('www.example.com', ['192.0.2.53'])
assert dns_force.domain == 'www.example.com'
assert all(candidate.endswith('.www.example.com') for candidate in dns_force.list)
@pytest.mark.asyncio
async def test_check_normalizes_and_deduplicates_mixed_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
values = {
'A': [('addr', '192.0.2.10'), ('addr', '192.0.2.10'), ('addr', '999.0.0.1')],
'AAAA': [('addr', '2001:0db8::10'), ('addr', '2001:db8:0:0::10')],
'CNAME': [('cname', 'Target.Example.NET.'), ('cname', 'target.example.net')],
}
class FakeResolver:
async def query_dns(self, _host: str, record_type: str):
records = [SimpleNamespace(data=SimpleNamespace(**{field: value})) for field, value in values[record_type]]
return SimpleNamespace(answer=records)
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['mixed.example.com'], nameservers=[])
resolved, hosts, addresses = await checker.check()
assert resolved == ['mixed.example.com:192.0.2.10,2001:db8::10']
assert hosts == ['mixed.example.com']
assert addresses == ['192.0.2.10', '2001:db8::10']
assert checker.records['mixed.example.com'] == hostchecker.HostDnsRecords(
ipv4=('192.0.2.10',),
ipv6=('2001:db8::10',),
cnames=('target.example.net',),
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'outcome',
[
hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENOTFOUND, 'not found'),
hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENODATA, 'no data'),
SimpleNamespace(answer=[]),
OSError('resolver error'),
TimeoutError('timed out'),
],
)
async def test_check_excludes_candidate_without_usable_evidence(
monkeypatch: pytest.MonkeyPatch,
outcome: object,
) -> None:
class FakeResolver:
async def query_dns(self, _host: str, _record_type: str):
if isinstance(outcome, BaseException):
raise outcome
return outcome
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['missing.example.com'], nameservers=[])
assert await checker.check() == ([], [], [])
assert checker.records == {}
@pytest.mark.asyncio
async def test_check_distinguishes_expected_absence_from_query_failures(monkeypatch: pytest.MonkeyPatch) -> None:
not_found = hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENOTFOUND, 'not found')
no_data = hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENODATA, 'no data')
class FakeResolver:
async def query_dns(self, host: str, _record_type: str):
if host == 'missing.example.com':
raise not_found
if host == 'empty.example.com':
raise no_data
raise TimeoutError('resolver timed out')
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(
['missing.example.com', 'empty.example.com', 'timeout.example.com'],
nameservers=[],
)
assert await checker.check() == ([], [], [])
assert checker.query_error_count == 3
assert checker.query_error_types == {'TimeoutError'}
@pytest.mark.asyncio
async def test_dns_force_defaults_diagnostics_for_existing_checker_contract(monkeypatch: pytest.MonkeyPatch) -> None:
class ExistingChecker:
records: ClassVar[dict[str, hostchecker.HostDnsRecords]] = {}
def __init__(self, _hosts: list[str], nameservers: list[str], **_kwargs: object) -> None:
assert nameservers == []
async def check(self) -> tuple[list[str], list[str], list[str]]:
return [], [], []
monkeypatch.setattr(dnssearch.hostchecker, 'Checker', ExistingChecker)
dns_force = dnssearch.DnsForce('example.com', [])
dns_force.list = []
assert await dns_force.run() == ([], [], [])
assert dns_force.query_error_count == 0
assert dns_force.query_error_types == set()
@pytest.mark.asyncio
async def test_reverse_single_ip_keeps_transport_failures_as_empty_results() -> None:
class FakeResolver:
async def gethostbyaddr(self, _ip: str):
raise TimeoutError('resolver timed out')
assert await dnssearch.reverse_single_ip('192.0.2.10', FakeResolver()) == ''
@pytest.mark.asyncio
async def test_reverse_range_reports_only_unexpected_ptr_errors(monkeypatch: pytest.MonkeyPatch) -> None:
not_found = hostchecker.aiodns.error.DNSError(hostchecker.aiodns.error.ARES_ENOTFOUND, 'not found')
class FakeResolver:
async def gethostbyaddr(self, ip: str):
if ip == '192.0.2.1':
return SimpleNamespace(name='api.example.com')
if ip == '192.0.2.2':
raise not_found
raise TimeoutError('resolver timed out')
monkeypatch.setattr(dnssearch, 'iter_ips_in_network_range', lambda _range: iter(['192.0.2.1', '192.0.2.2', '192.0.2.3']))
monkeypatch.setattr(dnssearch, 'DNSResolver', lambda **_kwargs: FakeResolver())
monkeypatch.setattr(dnssearch, 'log_query', lambda _ip: None)
results: list[str] = []
error_types: set[str] = set()
await dnssearch.reverse_all_ips_in_range(
'192.0.2.0/24',
results.append,
error_types=error_types,
)
assert results == ['api.example.com', '', '']
assert error_types == {'TimeoutError'}
@pytest.mark.asyncio
async def test_reverse_ranges_deduplicate_ips_and_bound_one_global_job_set(monkeypatch: pytest.MonkeyPatch) -> None:
queried: list[str] = []
active = 0
peak = 0
closed = False
class FakeResolver:
async def gethostbyaddr(self, ip: str):
nonlocal active, peak
queried.append(ip)
active += 1
peak = max(peak, active)
await asyncio.sleep(0)
active -= 1
return SimpleNamespace(name=f'host-{ip}.example.com')
async def close(self) -> None:
nonlocal closed
closed = True
ranges = {
'192.0.2.0/24': ['192.0.2.1', '192.0.2.2'],
'192.0.2.128/25': ['192.0.2.2', '192.0.2.3'],
}
monkeypatch.setattr(dnssearch, 'iter_ips_in_network_range', lambda iprange: iter(ranges[iprange]))
monkeypatch.setattr(dnssearch, 'DNSResolver', lambda **_kwargs: FakeResolver())
monkeypatch.setattr(dnssearch, 'log_query', lambda _ip: None)
found: list[str] = []
result = await dnssearch.reverse_ip_ranges(
tuple(ranges),
found.append,
concurrency=2,
)
assert sorted(queried) == ['192.0.2.1', '192.0.2.2', '192.0.2.3']
assert sorted(found) == [
'host-192.0.2.1.example.com',
'host-192.0.2.2.example.com',
'host-192.0.2.3.example.com',
]
assert peak == 2
assert result.request_count == 3
assert result.completed_count == 3
assert result.stop_reason is None
assert closed
@pytest.mark.asyncio
async def test_reverse_ranges_stop_at_query_budget_with_partial_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def gethostbyaddr(self, ip: str):
return SimpleNamespace(name=f'host-{ip}.example.com')
async def close(self) -> None:
return None
monkeypatch.setattr(
dnssearch,
'iter_ips_in_network_range',
lambda _range: iter(['192.0.2.1', '192.0.2.2']),
)
monkeypatch.setattr(dnssearch, 'DNSResolver', lambda **_kwargs: FakeResolver())
monkeypatch.setattr(dnssearch, 'log_query', lambda _ip: None)
found: list[str] = []
result = await dnssearch.reverse_ip_ranges(
('192.0.2.0/24',),
found.append,
concurrency=1,
request_limit=1,
)
assert found == ['host-192.0.2.1.example.com']
assert result.request_count == 1
assert result.completed_count == 1
assert result.stop_reason == 'query-limit'
@pytest.mark.asyncio
async def test_reverse_defaults_process_more_than_former_candidate_ceiling_lazily(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def gethostbyaddr(self, _ip: str):
return None
async def close(self) -> None:
return None
monkeypatch.setattr(
dnssearch,
'iter_ips_in_network_range',
lambda _range: (f'192.0.{index // 255}.{index % 255}' for index in range(1, 3_002)),
)
monkeypatch.setattr(dnssearch, 'DNSResolver', lambda **_kwargs: FakeResolver())
monkeypatch.setattr(dnssearch, 'log_query', lambda _ip: None)
result = await dnssearch.reverse_ip_ranges(('large-range',), lambda _host: None)
assert result == dnssearch.ReverseDNSResult(3_001, 3_001)
@pytest.mark.asyncio
async def test_reverse_ranges_stop_materializing_after_the_finite_budget(monkeypatch: pytest.MonkeyPatch) -> None:
generated = 0
def many_addresses(_range: str):
nonlocal generated
for index in range(1, 10_001):
generated += 1
yield f'192.0.{index // 255}.{index % 255}'
class FakeResolver:
async def gethostbyaddr(self, _ip: str):
return None
async def close(self) -> None:
return None
monkeypatch.setattr(dnssearch, 'iter_ips_in_network_range', many_addresses)
monkeypatch.setattr(dnssearch, 'DNSResolver', lambda **_kwargs: FakeResolver())
monkeypatch.setattr(dnssearch, 'log_query', lambda _ip: None)
result = await dnssearch.reverse_ip_ranges(
('large-range',),
lambda _host: None,
request_limit=3,
)
assert generated == 4
assert result.request_count == 3
assert result.completed_count == 3
assert result.stop_reason == 'query-limit'
@pytest.mark.asyncio
async def test_reverse_large_real_range_stays_bounded_before_the_request_limit(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class FakeResolver:
async def gethostbyaddr(self, _ip: str):
return None
async def close(self) -> None:
return None
monkeypatch.setattr(dnssearch, 'DNSResolver', lambda **_kwargs: FakeResolver())
monkeypatch.setattr(dnssearch, 'log_query', lambda _ip: None)
tracemalloc.start()
baseline, _peak = tracemalloc.get_traced_memory()
try:
result = await dnssearch.reverse_ip_ranges(
('10.0.0.0/16',),
lambda _host: None,
request_limit=3,
)
_current, peak = tracemalloc.get_traced_memory()
finally:
tracemalloc.stop()
assert result == dnssearch.ReverseDNSResult(3, 3, 'query-limit')
assert peak - baseline < 1_000_000
@pytest.mark.asyncio
async def test_reverse_ranges_propagate_resolver_close_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def gethostbyaddr(self, _ip: str):
return None
async def close(self) -> None:
raise asyncio.CancelledError('resolver-close-cancelled')
monkeypatch.setattr(dnssearch, 'iter_ips_in_network_range', lambda _range: iter(['192.0.2.1']))
monkeypatch.setattr(dnssearch, 'DNSResolver', lambda **_kwargs: FakeResolver())
monkeypatch.setattr(dnssearch, 'log_query', lambda _ip: None)
with pytest.raises(asyncio.CancelledError, match='reverse DNS resolver close cancelled'):
await dnssearch.reverse_ip_ranges(('192.0.2.0/24',), lambda _host: None)
@pytest.mark.asyncio
async def test_reverse_ranges_external_repeated_cancellation_closes_resolver_and_preserves_first_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
query_started = asyncio.Event()
close_started = asyncio.Event()
close_release = asyncio.Event()
closed = asyncio.Event()
class FakeResolver:
async def gethostbyaddr(self, _ip: str):
query_started.set()
await asyncio.Event().wait()
async def close(self) -> None:
close_started.set()
await close_release.wait()
closed.set()
monkeypatch.setattr(dnssearch, 'iter_ips_in_network_range', lambda _range: iter(['192.0.2.1']))
monkeypatch.setattr(dnssearch, 'DNSResolver', lambda **_kwargs: FakeResolver())
monkeypatch.setattr(dnssearch, 'log_query', lambda _ip: None)
task = asyncio.create_task(dnssearch.reverse_ip_ranges(('192.0.2.0/24',), lambda _host: None))
await query_started.wait()
task.cancel('ptr-query-cancelled')
await close_started.wait()
task.cancel('operator-stop-again')
await asyncio.sleep(0)
close_release.set()
with pytest.raises(asyncio.CancelledError) as raised:
await task
assert raised.value.args == ('ptr-query-cancelled',)
assert closed.is_set()
@pytest.mark.asyncio
async def test_check_propagates_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def query_dns(self, _host: str, _record_type: str):
raise asyncio.CancelledError
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['cancelled.example.com'], nameservers=[])
with pytest.raises(asyncio.CancelledError):
await checker.check()
@pytest.mark.asyncio
async def test_check_rejects_address_record_type_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
values = {'A': '2001:db8::10', 'AAAA': '192.0.2.10'}
class FakeResolver:
async def query_dns(self, _host: str, record_type: str):
if record_type == 'CNAME':
raise OSError('no data')
record = SimpleNamespace(data=SimpleNamespace(addr=values[record_type]))
return SimpleNamespace(answer=[record])
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['mismatch.example.com'], nameservers=[])
assert await checker.check() == ([], [], [])
@pytest.mark.asyncio
async def test_check_rejects_empty_cname(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResolver:
async def query_dns(self, _host: str, record_type: str):
if record_type == 'CNAME':
record = SimpleNamespace(data=SimpleNamespace(cname='.'))
return SimpleNamespace(answer=[record])
raise OSError('no data')
monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', lambda **_kwargs: FakeResolver())
checker = hostchecker.Checker(['empty.example.com'], nameservers=[])
assert await checker.check() == ([], [], [])