Files
theHarvester/tests/test_main.py
T
MattandGitHub b8a8f7c7ca fix: move Shodan discovery into adapter (#2535)
* fix: move Shodan discovery into adapter (#283)

* docs: clarify Shodan transport and persistence

* fix: query every Shodan-resolved IPv4

* fix: call Shodan Host API directly

* fix: serve HarvestView assets locally

* Revert "fix: serve HarvestView assets locally"

This reverts commit 413e8b25ab.

* docs: clarify Shodan proxy transport

* docs: clarify Shodan changelog entry

* feat: persist structured Shodan host evidence

* feat: expand Shodan discovery with TLS search
2026-08-14 21:11:12 -04:00

3495 lines
125 KiB
Python

import asyncio
import json
import logging
import sys
import xml.etree.ElementTree as ElementTree
from datetime import UTC, datetime
from pathlib import Path
from types import ModuleType
import pytest
from theHarvester import __main__ as theharvester_main
from theHarvester.discovery.constants import MissingKey
from theHarvester.lib.asn_attribution import AsnAttributionObservation
from theHarvester.lib.completed_result import CompletedResult, ResultObservation
from theHarvester.lib.dns_consensus import Addressability
from theHarvester.lib.enumeration import EnumerationOptions
from theHarvester.lib.hostchecker import HostDnsRecords
from theHarvester.lib.network_evidence import PrefixOriginObservation, RpkiValidationObservation
from theHarvester.lib.recursive_dns import RecursiveDNSClassification, RecursiveDNSFinding, RecursiveDNSResult
from theHarvester.lib.routeviews import RouteViewsCancelled, RouteViewsResult
from theHarvester.lib.virtual_host import (
HarvestedVirtualHostResult,
VirtualHostDiscoveryCancelled,
VirtualHostObservation,
)
@pytest.mark.asyncio
async def test_cli_help_explains_proxy_and_direct_action_scope(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
monkeypatch.setattr(sys, 'argv', ['theHarvester', '--help'])
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
help_text = ' '.join(capsys.readouterr().out.split())
assert exit_info.value.code == 0
assert 'Use proxies.yaml for supported discovery-source, Shodan, and takeover requests.' in help_text
assert 'Query the Shodan Host API for discovered IPs, using configured proxies when enabled.' in help_text
assert (
'Enrich discovered IPs with sourced ASN attribution, or an explicitly targeted ASN, IP, or prefix, through '
'RouteViews.' in help_text
)
assert 'Exclude hostname results while retaining other result types returned by selected sources.' in help_text
assert 'Accepted for compatibility but currently unused; use --dns-resolvers to select resolvers.' in help_text
assert 'Select resolver IPs for DNS actions without enabling hostname resolution.' in help_text
assert 'text file with one IP per line' in help_text
assert 'Perform PTR lookups across the /24 network containing each discovered IPv4 address.' in help_text
assert 'Multiple capabilities select the union of matching sources; they do not filter returned fields.' in help_text
assert 'Check common API paths with GET, HEAD, and OPTIONS.' in help_text
assert 'Requests follow redirects.' in help_text
assert 'virtual host discovery' in help_text
assert 'P2 direct interaction (active reconnaissance): sends direct HTTP and TLS requests.' in help_text
assert 'For normal use, pass only --vhost; bounded safety defaults apply automatically.' in help_text
assert 'virtual host advanced controls' in help_text
assert 'Candidate names are never resolved through DNS.' in help_text
def _confirmed_vhost(endpoint: str = 'http://192.0.2.10:80/') -> VirtualHostObservation:
return VirtualHostObservation(
endpoint=endpoint,
hostname='admin.example.com',
http_host='admin.example.com',
tls_server_name=None,
classification='distinct',
phase='body',
status=200,
location=None,
body_sha256='a' * 64,
body_size=5,
body_truncated=False,
tls_verified=None,
error_type=None,
distinct_signals=('body_sha256',),
reflection_normalized=False,
needs_confirmation=False,
context_phase='body',
context_status=200,
context_location=None,
context_body_sha256='b' * 64,
context_body_size=5,
context_body_truncated=False,
control_phase='body',
control_status=200,
control_location=None,
control_body_sha256='b' * 64,
control_body_size=5,
control_body_truncated=False,
confirmation_body_sha256='a' * 64,
)
@pytest.mark.asyncio
async def test_virtual_host_action_reaches_completed_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[dict[str, object]] = []
async def fake_discover(**kwargs: object) -> HarvestedVirtualHostResult:
calls.append(kwargs)
return HarvestedVirtualHostResult((_confirmed_vhost(),), 5, 1, 1, 1, 1, 'completed')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', fake_discover, raising=False)
response = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
vhost_endpoint='http://192.0.2.10/',
vhost_candidates=('admin.example.com',),
),
return_completed_result=True,
)
completed = response[-1]
assert isinstance(completed, CompletedResult)
assert calls[0]['scope'] == 'example.com'
assert calls[0]['addresses'] == ()
assert calls[0]['candidates'] == ('admin.example.com',)
assert calls[0]['endpoint_override'] == 'http://192.0.2.10:80/'
execution = next(item for item in completed.active_evidence.executions if item.action == 'vhost')
assert execution.status == 'completed'
assert {(item.kind, item.value) for item in execution.observations} == {('hostname', 'admin.example.com')}
assert completed.virtual_hosts == (_confirmed_vhost(),)
@pytest.mark.asyncio
async def test_virtual_host_inputs_fail_before_result_store_initialization(monkeypatch: pytest.MonkeyPatch) -> None:
class UnexpectedResultStore:
def __init__(self, *_args: object) -> None:
raise AssertionError('result store initialization must follow virtual-host validation')
monkeypatch.setattr(theharvester_main, 'ResultStore', UnexpectedResultStore)
with pytest.raises(ValueError, match='outside authorized scope'):
await theharvester_main.start(
EnumerationOptions(
domain='example.com',
vhost_endpoint='https://192.0.2.10/',
vhost_candidates=('admin.attacker.test',),
)
)
with pytest.raises(ValueError, match='direct transport only'):
await theharvester_main.start(
EnumerationOptions(
domain='example.com',
proxies=True,
vhost_endpoint='https://192.0.2.10/',
vhost_candidates=('admin.example.com',),
)
)
@pytest.mark.asyncio
async def test_virtual_host_action_uses_harvested_hostnames_and_ips(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[dict[str, object]] = []
class FakeRapidDNS:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'admin.example.com'}
async def get_host_ip_pairs(self) -> set[tuple[str, str]]:
return {('admin.example.com', '192.0.2.10')}
async def get_ips(self) -> set[str]:
return {'192.0.2.10'}
async def fake_discover(**kwargs: object) -> HarvestedVirtualHostResult:
calls.append(kwargs)
return HarvestedVirtualHostResult((), 5, 1, 1, 1, 1, 'completed')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.rapiddns, 'SearchRapidDns', FakeRapidDNS)
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', fake_discover)
response = await theharvester_main.start(
EnumerationOptions(domain='example.com', quiet=True, source='rapiddns', vhost=True),
return_completed_result=True,
)
assert calls[0]['addresses'] == ('192.0.2.10',)
assert calls[0]['candidates'] == ('admin.example.com',)
execution = next(item for item in response[-1].active_evidence.executions if item.action == 'vhost')
assert execution.status == 'completed'
assert execution.result_count == 0
@pytest.mark.asyncio
async def test_virtual_host_action_explains_missing_harvested_endpoints(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
response = await theharvester_main.start(
EnumerationOptions(domain='example.com', quiet=True, vhost_candidates=('admin.example.com',)),
return_completed_result=True,
)
execution = next(item for item in response[-1].active_evidence.executions if item.action == 'vhost')
assert execution.status == 'skipped'
assert execution.stop_reason == 'no-endpoints'
@pytest.mark.asyncio
async def test_virtual_host_action_reports_all_request_errors_as_failed(monkeypatch: pytest.MonkeyPatch) -> None:
async def failed_discovery(**_kwargs: object) -> HarvestedVirtualHostResult:
return HarvestedVirtualHostResult(
observations=(),
request_count=5,
endpoint_count=1,
total_endpoint_count=1,
candidate_endpoint_count=1,
total_candidate_endpoint_count=1,
stop_reason='request-errors',
request_error_count=5,
request_error_types=('TimeoutError',),
)
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', failed_discovery)
response = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
vhost_endpoint='http://192.0.2.10/',
vhost_candidates=('admin.example.com',),
),
return_completed_result=True,
)
execution = next(item for item in response[-1].active_evidence.executions if item.action == 'vhost')
assert execution.status == 'failed'
assert execution.error_type == 'TimeoutError'
assert execution.stop_reason == 'request-errors'
@pytest.mark.asyncio
async def test_virtual_host_action_reports_mixed_request_errors_without_a_finding_as_failed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def failed_discovery(**_kwargs: object) -> HarvestedVirtualHostResult:
return HarvestedVirtualHostResult(
observations=(),
request_count=5,
endpoint_count=1,
total_endpoint_count=1,
candidate_endpoint_count=1,
total_candidate_endpoint_count=1,
stop_reason='request-errors',
request_error_count=1,
request_error_types=('TimeoutError',),
)
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', failed_discovery)
response = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
vhost_endpoint='http://192.0.2.10/',
vhost_candidates=('admin.example.com',),
),
return_completed_result=True,
)
execution = next(item for item in response[-1].active_evidence.executions if item.action == 'vhost')
assert execution.status == 'failed'
assert execution.error_type == 'TimeoutError'
assert execution.stop_reason == 'request-errors'
@pytest.mark.asyncio
@pytest.mark.parametrize(
('stop_reason', 'scan_error_type', 'expected_error_type'),
[
('request-errors', None, 'TimeoutError'),
('scan-error', 'ConnectionError', 'ConnectionError'),
],
)
async def test_virtual_host_action_reports_mixed_or_scan_errors_as_partial(
monkeypatch: pytest.MonkeyPatch,
stop_reason: str,
scan_error_type: str | None,
expected_error_type: str,
) -> None:
saved: list[CompletedResult] = []
async def partial_discovery(**_kwargs: object) -> HarvestedVirtualHostResult:
return HarvestedVirtualHostResult(
observations=(_confirmed_vhost(),),
request_count=5,
endpoint_count=1,
total_endpoint_count=1,
candidate_endpoint_count=1,
total_candidate_endpoint_count=1,
stop_reason=stop_reason,
request_error_count=1,
request_error_types=('TimeoutError',),
scan_error_type=scan_error_type,
)
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', partial_discovery)
response = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
vhost_endpoint='http://192.0.2.10/',
vhost_candidates=('admin.example.com',),
),
return_completed_result=True,
)
completed = response[-1]
execution = next(item for item in completed.active_evidence.executions if item.action == 'vhost')
assert execution.status == 'partial'
assert execution.error_type == expected_error_type
assert execution.stop_reason == stop_reason
assert completed.virtual_hosts == (_confirmed_vhost(),)
assert saved[-1].virtual_hosts == (_confirmed_vhost(),)
@pytest.mark.asyncio
async def test_virtual_host_cancellation_persists_partial_observations_and_propagates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
saved: list[CompletedResult] = []
partial = HarvestedVirtualHostResult(
observations=(_confirmed_vhost(),),
request_count=5,
endpoint_count=1,
total_endpoint_count=2,
candidate_endpoint_count=1,
total_candidate_endpoint_count=2,
stop_reason='cancelled',
request_error_count=1,
request_error_types=('TimeoutError',),
scan_error_type='CancelledError',
)
async def cancelled_discovery(**_kwargs: object) -> HarvestedVirtualHostResult:
raise VirtualHostDiscoveryCancelled(partial)
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', cancelled_discovery)
with pytest.raises(VirtualHostDiscoveryCancelled):
await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
vhost_endpoint='http://192.0.2.10/',
vhost_candidates=('admin.example.com',),
),
return_completed_result=True,
)
completed = saved[-1]
execution = next(item for item in completed.active_evidence.executions if item.action == 'vhost')
assert execution.status == 'partial'
assert execution.error_type == 'CancelledError'
assert execution.stop_reason == 'cancelled'
assert completed.virtual_hosts == (_confirmed_vhost(),)
@pytest.mark.asyncio
async def test_virtual_host_cancellation_persists_failure_and_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
saved: list[CompletedResult] = []
async def cancelled_discovery(**_kwargs: object) -> HarvestedVirtualHostResult:
raise asyncio.CancelledError
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', cancelled_discovery)
with pytest.raises(asyncio.CancelledError):
await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
vhost_endpoint='http://192.0.2.10/',
vhost_candidates=('admin.example.com',),
),
return_completed_result=True,
)
execution = next(item for item in saved[-1].active_evidence.executions if item.action == 'vhost')
assert execution.status == 'failed'
assert execution.error_type == 'CancelledError'
assert execution.stop_reason == 'cancelled'
@pytest.mark.asyncio
async def test_virtual_host_carried_cancellation_without_a_finding_is_failed(
monkeypatch: pytest.MonkeyPatch,
) -> None:
saved: list[CompletedResult] = []
cancelled = HarvestedVirtualHostResult(
observations=(),
request_count=5,
endpoint_count=1,
total_endpoint_count=2,
candidate_endpoint_count=1,
total_candidate_endpoint_count=2,
stop_reason='cancelled',
scan_error_type='CancelledError',
)
async def cancelled_discovery(**_kwargs: object) -> HarvestedVirtualHostResult:
raise VirtualHostDiscoveryCancelled(cancelled)
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', cancelled_discovery)
with pytest.raises(VirtualHostDiscoveryCancelled):
await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
vhost_endpoint='http://192.0.2.10/',
vhost_candidates=('admin.example.com',),
),
return_completed_result=True,
)
execution = next(item for item in saved[-1].active_evidence.executions if item.action == 'vhost')
assert execution.status == 'failed'
assert execution.error_type == 'CancelledError'
assert execution.stop_reason == 'cancelled'
@pytest.mark.asyncio
@pytest.mark.parametrize('stop_reason', ['request-limit', 'runtime-limit'])
async def test_virtual_host_limits_remain_partial_without_a_finding(
monkeypatch: pytest.MonkeyPatch,
stop_reason: str,
) -> None:
async def limited_discovery(**_kwargs: object) -> HarvestedVirtualHostResult:
return HarvestedVirtualHostResult((), 4, 1, 1, 0, 1, stop_reason)
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', limited_discovery)
response = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
vhost_endpoint='http://192.0.2.10/',
vhost_candidates=('admin.example.com',),
),
return_completed_result=True,
)
execution = next(item for item in response[-1].active_evidence.executions if item.action == 'vhost')
assert execution.status == 'partial'
assert execution.stop_reason == stop_reason
@pytest.mark.asyncio
async def test_virtual_host_output_keeps_legacy_lists_and_structured_jsonl(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
output_path = tmp_path / 'vhost-report'
async def fake_discover(**_kwargs: object) -> HarvestedVirtualHostResult:
return HarvestedVirtualHostResult((_confirmed_vhost(),), 5, 1, 1, 1, 1, 'completed')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'discover_harvested_virtual_hosts', fake_discover)
monkeypatch.setattr(
sys,
'argv',
[
'theHarvester',
'-d',
'example.com',
'--vhost-endpoint',
'http://192.0.2.10/',
'--vhost-candidate',
'admin.example.com',
'-f',
str(output_path),
],
)
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
assert exit_info.value.code == 0
assert json.loads(output_path.with_suffix('.json').read_text())['vhosts'] == ['admin.example.com']
assert [item.text for item in ElementTree.parse(output_path.with_suffix('.xml')).getroot().findall('vhost')] == [
'admin.example.com'
]
jsonl = [json.loads(line) for line in output_path.with_suffix('.jsonl').read_text().splitlines()]
finding = next(item for item in jsonl if item['type'] == 'hostname')
assert finding['value'] == 'admin.example.com'
assert finding['actions'] == ['vhost']
assert len(finding['observations']) == 1
assert finding['observations'][0]['endpoint'] == 'http://192.0.2.10:80/'
@pytest.mark.parametrize('target', ['Example.COM.', 'WWW.Example.COM.'])
def test_normalize_hosts_for_storage_uses_the_parser_scope(target: str) -> None:
discovered_hosts: set[object] = {
'API.Example.COM.',
'example.com',
'badexample.com',
'example.com.attacker.test',
123,
}
assert theharvester_main._normalize_hosts_for_storage(discovered_hosts, target) == {'api.example.com'}
@pytest.mark.asyncio
async def test_rapiddns_hostnames_honor_explicit_dns_resolution(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
completed: list[CompletedResult] = []
output_directory = tmp_path / 'reports.v1'
output_directory.mkdir()
output_path = output_directory / 'rapiddns'
class FakeResultStore:
async def initialize(self) -> None:
return None
async def record_observations(self, *_args: object) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class FakeRapidDNS:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'api.example.com', 'reported.example.com'}
async def get_host_ip_pairs(self) -> set[tuple[str, str]]:
return {('reported.example.com', '192.0.2.20')}
async def get_ips(self) -> set[str]:
return {'192.0.2.20'}
class FakeCrtsh:
execution_status = 'partial'
stop_reason = 'invalid-response'
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'crt.example.com'}
class FakeChecker:
def __init__(self, hosts: list[str], nameservers: list[str]) -> None:
assert nameservers == ['192.0.2.53']
self.hosts = hosts
self.query_error_count = 1
self.query_error_types = {'TimeoutError'}
async def check(self) -> tuple[list[str], list[str], list[str]]:
if self.hosts == ['crt.example.com']:
return ['crt.example.com:192.0.2.30'], ['crt.example.com'], ['192.0.2.30']
assert self.hosts == ['api.example.com']
return (
['api.example.com:192.0.2.10', 'reported.example.com:192.0.2.21'],
['api.example.com', 'reported.example.com'],
['192.0.2.10', '192.0.2.21'],
)
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.rapiddns, 'SearchRapidDns', FakeRapidDNS)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', FakeCrtsh)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
monkeypatch.setattr(
sys,
'argv',
[
'theHarvester',
'-d',
'example.com',
'-b',
'crtsh,rapiddns',
'-r',
'192.0.2.53',
'-f',
str(output_path),
],
)
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
assert exit_info.value.code == 0
assert ('hostname', 'api.example.com') in completed[0].results
assert ('hostname', 'crt.example.com') in completed[0].results
assert ('hostname', 'reported.example.com') in completed[0].results
assert ('ip', '192.0.2.10') in completed[0].results
assert ('ip', '192.0.2.20') in completed[0].results
assert ('ip', '192.0.2.21') in completed[0].results
assert ('ip', '192.0.2.30') in completed[0].results
assert {execution.source for execution in completed[0].source_executions} == {'crtsh', 'rapiddns'}
crtsh_execution = next(execution for execution in completed[0].source_executions if execution.source == 'crtsh')
assert crtsh_execution.status == 'partial'
assert crtsh_execution.stop_reason == 'invalid-response'
dns_execution = completed[0].active_evidence.executions[0]
assert dns_execution.action == 'dns-resolve'
assert dns_execution.status == 'partial'
assert dns_execution.error_type == 'TimeoutError'
assert dns_execution.stop_reason == 'query-errors'
assert {(observation.kind, observation.value) for observation in dns_execution.observations} == {
('ip', '192.0.2.10'),
('ip', '192.0.2.21'),
('ip', '192.0.2.30'),
}
assert ('ip', '192.0.2.20') not in {(observation.kind, observation.value) for observation in dns_execution.observations}
assert completed[0].evidence_dict()['status'] == 'partial'
assert {(observation.source, observation.kind, observation.value) for observation in completed[0].observations} >= {
('crtsh', 'hostname', 'crt.example.com'),
('rapiddns', 'hostname', 'api.example.com'),
('rapiddns', 'hostname', 'reported.example.com'),
('rapiddns', 'ip', '192.0.2.20'),
}
assert 'reported.example.com:192.0.2.21' in json.loads(output_path.with_suffix('.json').read_text())['hosts']
assert output_path.with_suffix('.jsonl').is_file()
xml_pairs = [
(element.findtext('hostname'), element.findtext('ip'))
for element in ElementTree.parse(output_path.with_suffix('.xml')).getroot().findall('host')
]
assert xml_pairs.count(('reported.example.com', '192.0.2.20')) == 1
assert xml_pairs.count(('reported.example.com', '192.0.2.21')) == 1
@pytest.mark.asyncio
async def test_dns_brute_utility_persists_action_evidence_before_return(monkeypatch: pytest.MonkeyPatch) -> None:
completed: list[CompletedResult] = []
legacy_writes: list[tuple[object, ...]] = []
resolved = ['api.example.com:192.0.2.10']
class FakeResultStore:
async def initialize(self) -> None:
return None
async def record_observations(self, *args: object) -> None:
legacy_writes.append(args)
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class FakeDnsForce:
query_error_count = 1
query_error_types = {'TimeoutError'}
def __init__(self, domain: str, nameservers: list[str], verbose: bool) -> None:
assert domain == 'example.com'
assert nameservers == []
assert verbose is True
async def run(self) -> tuple[list[str], list[str], list[str]]:
return resolved, ['API.Example.COM.'], ['192.0.2.10', 'not-an-ip']
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.dnssearch, 'DnsForce', FakeDnsForce)
response = await theharvester_main.start(
EnumerationOptions(domain='example.com', source='', dns_brute=True, quiet=True),
return_dns_brute_result=True,
)
assert response == resolved
assert len(completed) == 1
execution = completed[0].active_evidence.executions[0]
assert execution.action == 'dns-brute'
assert execution.status == 'partial'
assert execution.error_type == 'TimeoutError'
assert execution.stop_reason == 'query-errors'
assert {(observation.kind, observation.value) for observation in execution.observations} == {
('hostname', 'api.example.com'),
('ip', '192.0.2.10'),
}
assert legacy_writes == []
@pytest.mark.asyncio
async def test_dns_brute_query_errors_are_partial_even_without_findings(monkeypatch: pytest.MonkeyPatch) -> None:
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class EmptyDnsForce:
query_error_count = 1
query_error_types = {'TimeoutError'}
def __init__(self, *_args, **_kwargs) -> None:
pass
async def run(self) -> tuple[list[str], list[str], list[str]]:
return [], [], []
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.dnssearch, 'DnsForce', EmptyDnsForce)
assert (
await theharvester_main.start(
EnumerationOptions(domain='example.com', source='', dns_brute=True, quiet=True),
return_dns_brute_result=True,
)
== []
)
execution = completed[0].active_evidence.executions[0]
assert execution.status == 'partial'
assert execution.result_count == 0
assert execution.error_type == 'TimeoutError'
assert execution.stop_reason == 'query-errors'
@pytest.mark.asyncio
async def test_dns_brute_keeps_legacy_json_and_xml_while_persisting_canonical_evidence(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
stored: list[CompletedResult] = []
output_path = tmp_path / 'dns-brute'
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
stored.append(result)
class FakeDnsForce:
query_error_count = 0
query_error_types: set[str] = set()
def __init__(self, *_args, **_kwargs) -> None:
pass
async def run(self) -> tuple[list[str], list[str], list[str]]:
return ['api.example.com:192.0.2.10'], ['api.example.com'], ['192.0.2.10']
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.dnssearch, 'DnsForce', FakeDnsForce)
response = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
source='',
dns_brute=True,
filename=str(output_path),
quiet=True,
),
return_completed_result=True,
)
legacy_json = json.loads(output_path.with_suffix('.json').read_text())
assert legacy_json['hosts'] == ['api.example.com:192.0.2.10']
assert 'ips' not in legacy_json
assert response[6] == []
xml_pairs = [
(element.findtext('hostname'), element.findtext('ip'))
for element in ElementTree.parse(output_path.with_suffix('.xml')).getroot().findall('host')
]
assert xml_pairs == [('api.example.com', '192.0.2.10')]
completed = response[-1]
assert isinstance(completed, CompletedResult)
assert ('hostname', 'api.example.com') in completed.results
assert ('ip', '192.0.2.10') in completed.results
assert stored == [completed]
@pytest.mark.parametrize('error_type', [RuntimeError, asyncio.CancelledError])
@pytest.mark.asyncio
async def test_dns_brute_failure_persists_before_propagation(
monkeypatch: pytest.MonkeyPatch,
error_type: type[BaseException],
) -> None:
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class FailingDnsForce:
def __init__(self, *_args, **_kwargs) -> None:
pass
async def run(self):
raise error_type()
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.dnssearch, 'DnsForce', FailingDnsForce)
with pytest.raises(error_type):
await theharvester_main.start(
EnumerationOptions(domain='example.com', source='', dns_brute=True, quiet=True),
return_dns_brute_result=True,
)
assert len(completed) == 1
execution = completed[0].active_evidence.executions[0]
assert execution.action == 'dns-brute'
assert execution.status == 'failed'
assert execution.error_type == error_type.__name__
assert execution.stop_reason == ('cancelled' if issubclass(error_type, asyncio.CancelledError) else None)
@pytest.mark.asyncio
async def test_dns_resolve_cancellation_closes_workers_and_persists_before_propagation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class FakeSource:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'api.example.com'}
class CancelledChecker:
def __init__(self, _hosts: list[str], _nameservers: list[str]) -> None:
pass
async def check(self):
raise asyncio.CancelledError
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.certspottersearch, 'SearchCertspoter', FakeSource)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', FakeSource)
monkeypatch.setattr(theharvester_main.shodanct, 'SearchShodanCt', FakeSource)
monkeypatch.setattr(theharvester_main.subdomaincenter, 'SubdomainCenter', FakeSource)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', CancelledChecker)
with pytest.raises(asyncio.CancelledError):
await asyncio.wait_for(
theharvester_main.start(
EnumerationOptions(
domain='example.com',
source='certspotter,crtsh,shodanct,subdomaincenter',
dns_resolve='192.0.2.53',
quiet=True,
)
),
timeout=1,
)
assert len(completed) == 1
execution = completed[0].active_evidence.executions[0]
assert execution.action == 'dns-resolve'
assert execution.status == 'failed'
assert execution.error_type == 'CancelledError'
assert execution.stop_reason == 'cancelled'
@pytest.mark.asyncio
async def test_source_cancellation_before_dns_resolution_does_not_claim_dns_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class CancelledSource:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
raise asyncio.CancelledError
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.certspottersearch, 'SearchCertspoter', CancelledSource)
with pytest.raises(asyncio.CancelledError):
await theharvester_main.start(
EnumerationOptions(
domain='example.com',
source='certspotter',
dns_resolve='192.0.2.53',
quiet=True,
)
)
assert len(completed) == 1
assert completed[0].active_evidence.executions == ()
assert len(completed[0].source_executions) == 1
source_execution = completed[0].source_executions[0]
assert source_execution.source == 'certspotter'
assert source_execution.status == 'failed'
assert source_execution.error_type == 'CancelledError'
assert source_execution.stop_reason == 'cancelled'
assert completed[0].evidence_dict()['status'] == 'failed'
@pytest.mark.asyncio
async def test_dns_resolve_query_errors_are_partial_even_without_findings(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, _result: CompletedResult) -> None:
return None
class FakeSource:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'api.example.com'}
class EmptyChecker:
query_error_count = 1
query_error_types = {'TimeoutError'}
def __init__(self, hosts: list[str], _nameservers: list[str]) -> None:
assert hosts == ['api.example.com']
async def check(self) -> tuple[list[str], list[str], list[str]]:
return [], [], []
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.certspottersearch, 'SearchCertspoter', FakeSource)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', EmptyChecker)
response = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
source='certspotter',
dns_resolve='192.0.2.53',
quiet=True,
),
return_completed_result=True,
)
completed = response[-1]
assert isinstance(completed, CompletedResult)
execution = completed.active_evidence.executions[0]
assert execution.action == 'dns-resolve'
assert execution.status == 'partial'
assert execution.result_count == 0
assert execution.error_type == 'TimeoutError'
assert execution.stop_reason == 'query-errors'
@pytest.mark.asyncio
async def test_requested_dns_resolve_without_inputs_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, _result: CompletedResult) -> None:
return None
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
response = await theharvester_main.start(
EnumerationOptions(domain='example.com', source='', dns_resolve='192.0.2.53', quiet=True),
return_completed_result=True,
)
completed = response[-1]
assert isinstance(completed, CompletedResult)
assert len(completed.active_evidence.executions) == 1
execution = completed.active_evidence.executions[0]
assert execution.action == 'dns-resolve'
assert execution.status == 'skipped'
assert execution.stop_reason == 'no-input'
@pytest.mark.asyncio
async def test_rest_dns_lookup_runs_before_return_and_retains_action_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class FakeSecurityScorecard:
def __init__(self, domain: str) -> None:
assert domain == 'example.com'
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return set()
async def get_ips(self) -> set[str]:
return {'192.0.2.10'}
async def fake_reverse(
iprange: str,
callback,
nameservers: list[str] | None = None,
error_types: set[str] | None = None,
) -> None:
assert iprange == '192.0.2.0/24'
assert nameservers is None
callback('PTR.example.com.')
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.securityscorecard, 'SearchSecurityScorecard', FakeSecurityScorecard)
monkeypatch.setattr(theharvester_main.dnssearch, 'reverse_all_ips_in_range', fake_reverse)
response = await theharvester_main.start(
EnumerationOptions(domain='example.com', source='securityscorecard', dns_lookup=True, quiet=True),
persist_completed_result=True,
)
assert len(response) == 9
assert response[8] == ['ptr.example.com']
assert len(completed) == 1
execution = completed[0].active_evidence.executions[0]
assert execution.action == 'dns-lookup'
assert execution.status == 'completed'
assert execution.error_type is None
assert execution.stop_reason is None
assert execution.observations[0].kind == 'hostname'
assert execution.observations[0].value == 'ptr.example.com'
@pytest.mark.asyncio
async def test_requested_dns_lookup_without_ip_ranges_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, _result: CompletedResult) -> None:
return None
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
response = await theharvester_main.start(
EnumerationOptions(domain='example.com', source='', dns_lookup=True, quiet=True),
return_completed_result=True,
)
completed = response[-1]
assert isinstance(completed, CompletedResult)
assert len(completed.active_evidence.executions) == 1
execution = completed.active_evidence.executions[0]
assert execution.action == 'dns-lookup'
assert execution.status == 'skipped'
assert execution.result_count == 0
assert execution.stop_reason == 'no-input'
@pytest.mark.asyncio
async def test_dns_lookup_cancels_sibling_ranges_and_persists_partial_evidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
completed: list[CompletedResult] = []
sibling_started = asyncio.Event()
sibling_cancelled = asyncio.Event()
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class FakeSecurityScorecard:
def __init__(self, _domain: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return set()
async def get_ips(self) -> set[str]:
return {'192.0.2.10', '198.51.100.10'}
async def fake_reverse(
iprange: str,
callback,
nameservers: list[str] | None = None,
error_types: set[str] | None = None,
) -> None:
assert nameservers is None
if iprange == '192.0.2.0/24':
callback('partial.example.com')
await sibling_started.wait()
raise asyncio.CancelledError
sibling_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
sibling_cancelled.set()
raise
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.securityscorecard, 'SearchSecurityScorecard', FakeSecurityScorecard)
monkeypatch.setattr(theharvester_main.dnssearch, 'reverse_all_ips_in_range', fake_reverse)
with pytest.raises(asyncio.CancelledError):
await theharvester_main.start(
EnumerationOptions(domain='example.com', source='securityscorecard', dns_lookup=True, quiet=True),
persist_completed_result=True,
)
assert sibling_cancelled.is_set()
assert len(completed) == 1
execution = completed[0].active_evidence.executions[0]
assert execution.action == 'dns-lookup'
assert execution.status == 'partial'
assert execution.error_type == 'CancelledError'
assert execution.stop_reason == 'cancelled'
assert [(observation.kind, observation.value) for observation in execution.observations] == [
('hostname', 'partial.example.com')
]
@pytest.mark.parametrize(
('source', 'module', 'constructor_name'),
[
('projectdiscovery', theharvester_main.projectdiscovery, 'SearchDiscovery'),
('bevigil', theharvester_main.bevigil, 'SearchBeVigil'),
],
)
@pytest.mark.asyncio
async def test_constructor_missing_credentials_are_persisted_as_skipped_source(
monkeypatch: pytest.MonkeyPatch,
source: str,
module: ModuleType,
constructor_name: str,
) -> None:
saved_results: list[CompletedResult] = []
class RecordingResultStore(_NoopResultStore):
async def save_run(self, result: CompletedResult) -> None:
saved_results.append(result)
class MissingCredentialSource:
construction_count = 0
def __init__(self, _word: str) -> None:
type(self).construction_count += 1
raise MissingKey(source)
monkeypatch.setattr(theharvester_main, 'ResultStore', RecordingResultStore)
monkeypatch.setattr(module, constructor_name, MissingCredentialSource)
response = await theharvester_main.start(
EnumerationOptions(domain='example.com', source=source, quiet=True),
return_completed_result=True,
)
completed = response[-1]
assert isinstance(completed, CompletedResult)
assert saved_results == [completed]
assert MissingCredentialSource.construction_count == 1
assert len(completed.source_executions) == 1
execution = completed.source_executions[0]
assert execution.source == source
assert execution.status == 'skipped'
assert execution.result_count == 0
assert execution.error_type == 'MissingKeyError'
assert execution.stop_reason == 'missing-credentials'
@pytest.mark.asyncio
async def test_source_failure_retains_normalized_partial_results(monkeypatch: pytest.MonkeyPatch) -> None:
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class PartiallyFailingBuiltWith:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> list[str]:
return ['API.Example.COM.', 'api.example.com', 'outside.test']
async def get_urls(self) -> set[str]:
raise RuntimeError('provider page failed')
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.builtwith, 'SearchBuiltWith', PartiallyFailingBuiltWith)
monkeypatch.setattr(sys, 'argv', ['theHarvester', '-d', 'example.com', '-b', 'builtwith'])
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
assert exit_info.value.code == 0
assert len(completed) == 1
execution = completed[0].source_executions[0]
assert execution.status == 'partial'
assert execution.result_count == 1
assert execution.error_type == 'RuntimeError'
assert completed[0].observations == (ResultObservation('builtwith', 'hostname', 'api.example.com'),)
@pytest.mark.asyncio
async def test_source_checkpoint_excludes_other_source_work_in_progress(monkeypatch: pytest.MonkeyPatch) -> None:
builtwith_collected = asyncio.Event()
release_builtwith = asyncio.Event()
checkpoints: list[CompletedResult] = []
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class PausedBuiltWith:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
builtwith_collected.set()
return {'early.example.com'}
async def get_urls(self) -> set[str]:
await release_builtwith.wait()
return set()
async def get_frameworks(self) -> set[str]:
return set()
async def get_languages(self) -> set[str]:
return set()
async def get_servers(self) -> set[str]:
return set()
async def get_cms(self) -> set[str]:
return set()
async def get_analytics(self) -> set[str]:
return set()
class FastCrtsh:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
await builtwith_collected.wait()
async def get_hostnames(self) -> set[str]:
return {'committed.example.com'}
async def capture_checkpoint(result: CompletedResult) -> None:
checkpoints.append(result)
if {execution.source for execution in result.source_executions} == {'crtsh'}:
release_builtwith.set()
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.builtwith, 'SearchBuiltWith', PausedBuiltWith)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', FastCrtsh)
monkeypatch.setattr(sys, 'argv', ['theHarvester', '-d', 'example.com', '-b', 'builtwith,crtsh'])
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start(completed_result_checkpoint=capture_checkpoint)
assert exit_info.value.code == 0
crtsh_checkpoint = next(
result for result in checkpoints if {execution.source for execution in result.source_executions} == {'crtsh'}
)
assert ('hostname', 'committed.example.com') in crtsh_checkpoint.results
assert ('hostname', 'early.example.com') not in crtsh_checkpoint.results
assert {value for kind, value in completed[0].results if kind == 'hostname'} == {
'committed.example.com',
'early.example.com',
}
@pytest.mark.asyncio
async def test_invalid_source_outcome_is_not_recorded_as_completed(monkeypatch: pytest.MonkeyPatch) -> None:
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class InvalidOutcomeCrtsh:
execution_status = 'typo'
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return set()
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', InvalidOutcomeCrtsh)
monkeypatch.setattr(sys, 'argv', ['theHarvester', '-d', 'example.com', '-b', 'crtsh'])
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
assert exit_info.value.code == 0
assert completed[0].source_executions[0].status == 'failed'
assert completed[0].source_executions[0].error_type == 'ValueError'
@pytest.mark.asyncio
async def test_recursive_dns_requires_canonically_distinct_resolvers(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResultStore:
async def initialize(self) -> None:
return None
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(
sys,
'argv',
[
'theHarvester',
'-d',
'example.com',
'-b',
'crtsh',
'-r',
'2001:db8::1,2001:0db8:0:0:0:0:0:1,192.0.2.53',
'--dns-recursive-depth',
'1',
],
)
with pytest.raises(ValueError, match='exactly three resolver addresses'):
await theharvester_main.start()
@pytest.mark.asyncio
async def test_cli_rejects_resolver_file_with_non_ip_value(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
class FakeResultStore:
async def initialize(self) -> None:
return None
resolvers = tmp_path / 'resolvers.txt'
resolvers.write_text('192.0.2.53\nnot-an-ip\n', encoding='utf-8')
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
with pytest.raises(ValueError, match='Invalid DNS resolver address: not-an-ip'):
await theharvester_main.start(EnumerationOptions(domain='example.com', dns_resolve=str(resolvers), quiet=True))
@pytest.mark.asyncio
async def test_dns_brute_resolver_configuration_does_not_enable_dns_resolution(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, _result: CompletedResult) -> None:
return None
class FakeDnsForce:
def __init__(self, domain: str, nameservers: list[str], verbose: bool) -> None:
assert domain == 'www.example.com'
assert nameservers == ['192.0.2.53']
assert verbose is True
async def run(self) -> tuple[list[str], list[str], list[str]]:
return (
['dev.www.example.com:192.0.2.10'],
['dev.www.example.com'],
['192.0.2.10'],
)
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.dnssearch, 'DnsForce', FakeDnsForce)
resolvers = tmp_path / 'resolvers.txt'
resolvers.write_text('192.0.2.53\n', encoding='utf-8')
monkeypatch.setattr(
sys,
'argv',
[
'theHarvester',
'-d',
'www.example.com',
'-c',
'--dns-resolvers',
str(resolvers),
'--quiet',
],
)
checkpoints: list[CompletedResult] = []
async def checkpoint(result: CompletedResult) -> None:
checkpoints.append(result)
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start(completed_result_checkpoint=checkpoint)
assert exit_info.value.code == 0
completed = checkpoints[-1]
actions = {execution.action for execution in completed.active_evidence.executions}
assert 'dns-brute' in actions
assert 'dns-resolve' not in actions
@pytest.mark.asyncio
async def test_dns_proven_cname_hosts_reach_screenshot_filter(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
visited: set[str] = set()
class FakeResultStore:
async def initialize(self) -> None:
return None
async def record_observations(self, *_args) -> None:
return None
async def save_run(self, _result: CompletedResult) -> None:
return None
class FakeCrtsh:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'address.example.com', 'alias.example.com', 'unresolved.example.com'}
class FakeChecker:
def __init__(self, hosts: list[str], _nameservers: list[str]) -> None:
assert set(hosts) == {'address.example.com', 'alias.example.com', 'unresolved.example.com'}
async def check(self) -> tuple[list[str], list[str], list[str]]:
return (
['address.example.com:192.0.2.1', 'alias.example.com'],
['address.example.com', 'alias.example.com'],
['192.0.2.1'],
)
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, host: str) -> tuple[str, str]:
visited.add(host)
return host, 'https'
@staticmethod
def chunk_list(values: list[str], _size: int) -> list[list[str]]:
return [values]
async def take_screenshot(self, host: str, *, output_path: Path | None = None) -> str:
path = output_path or self.screenshot_path(host)
path.write_bytes(b'png')
return f'https://{host}'
def screenshot_path(self, host: str) -> Path:
return Path(self.output) / f'{host.removeprefix("https://")}.png'
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self) -> 'FakePool':
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', FakeCrtsh)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
monkeypatch.setattr(
sys,
'argv',
[
'theHarvester',
'-d',
'example.com',
'-b',
'crtsh',
'-r',
'192.0.2.53',
'--screenshot',
str(tmp_path),
],
)
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
assert exit_info.value.code == 0
assert visited == {'address.example.com', 'alias.example.com'}
class _NoopResultStore:
async def initialize(self) -> None:
return None
async def record_observations(self, *_args: object) -> None:
return None
async def save_run(self, _result: CompletedResult) -> None:
return None
class _ApiHostSource:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'api.example.com'}
class _ApiHostChecker:
def __init__(self, _hosts: list[str], _nameservers: list[str]) -> None:
pass
async def check(self) -> tuple[list[str], list[str], list[str]]:
return ['api.example.com:192.0.2.10'], ['api.example.com'], ['192.0.2.10']
def _recording_result_store(saved: list[CompletedResult]) -> type[_NoopResultStore]:
class RecordingResultStore(_NoopResultStore):
async def save_run(self, result: CompletedResult) -> None:
saved.append(result)
return RecordingResultStore
@pytest.mark.asyncio
async def test_cli_can_capture_an_explicit_target_without_discovery_sources(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
captured: list[str] = []
saved: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
saved.append(result)
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, host: str) -> tuple[str, str]:
return host, 'https'
@staticmethod
def chunk_list(values: list[str], _size: int) -> list[list[str]]:
return [values]
async def take_screenshot(self, host: str, *, output_path: Path | None = None) -> str:
captured.append(host)
(output_path or self.screenshot_path(host)).write_bytes(b'png')
return f'https://{host}'
def screenshot_path(self, host: str) -> Path:
return Path(self.output) / f'{host.removeprefix("https://")}.png'
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self) -> 'FakePool':
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
monkeypatch.setattr(
sys,
'argv',
['theHarvester', '-d', 'api.example.com', '--screenshot', str(tmp_path), '--quiet'],
)
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
assert exit_info.value.code == 0
assert captured == ['api.example.com']
completed = saved[-1]
execution = next(item for item in completed.active_evidence.executions if item.action == 'screenshot')
assert execution.status == 'completed'
assert execution.result_count == 0
assert ('screenshot', 'https://api.example.com') not in completed.results
assert ('hostname', 'api.example.com') in completed.results
assert len(execution.artifacts) == 1
artifact = execution.artifacts[0]
assert artifact.subject_value == 'api.example.com'
assert artifact.path == f'{tmp_path.name}/api.example.com.png'
assert artifact.media_type == 'image/png'
assert artifact.size_bytes == 3
@pytest.mark.asyncio
async def test_screenshot_reports_no_reachable_target_as_failed(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, _host: str) -> tuple[str, str]:
return '', ''
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
result = await theharvester_main.start(
EnumerationOptions(domain='api.example.test', screenshot=str(tmp_path), quiet=True),
return_completed_result=True,
)
execution = next(item for item in result[-1].active_evidence.executions if item.action == 'screenshot')
assert execution.status == 'failed'
assert execution.stop_reason == 'no-reachable-targets'
@pytest.mark.asyncio
async def test_screenshot_redirect_stays_attached_to_the_authorized_host(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, host: str) -> tuple[str, str]:
assert host == 'api.example.test'
return 'https://outside.example/path', 'reachable'
async def take_screenshot(self, url: str, *, output_path: Path | None = None) -> str:
(output_path or self.screenshot_path(url)).write_bytes(b'png')
return url
def screenshot_path(self, _url: str) -> Path:
return Path(self.output) / 'outside.example.png'
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
result = await theharvester_main.start(
EnumerationOptions(domain='api.example.test', screenshot=str(tmp_path), quiet=True),
return_completed_result=True,
)
completed = result[-1]
execution = next(item for item in completed.active_evidence.executions if item.action == 'screenshot')
assert execution.artifacts[0].subject_value == 'api.example.test'
assert ('hostname', 'outside.example') not in completed.results
@pytest.mark.asyncio
async def test_target_only_ip_screenshot_keeps_ip_result_and_artifact_subject(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, host: str) -> tuple[str, str]:
return f'https://{host}', 'reachable'
async def take_screenshot(self, url: str, *, output_path: Path | None = None) -> str:
assert output_path is not None
output_path.write_bytes(b'png') # noqa: ASYNC240 - tiny in-memory screenshot fixture
return url
def screenshot_path(self, url: str) -> Path:
return Path(self.output) / f'{url.removeprefix("https://")}.png'
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
result = await theharvester_main.start(
EnumerationOptions(domain='192.0.2.1', screenshot=str(tmp_path), quiet=True),
return_completed_result=True,
)
completed = result[-1]
execution = next(item for item in completed.active_evidence.executions if item.action == 'screenshot')
assert ('ip', '192.0.2.1') in completed.results
assert ('hostname', '192.0.2.1') not in completed.results
assert execution.artifacts[0].subject_kind == 'ip'
assert execution.artifacts[0].subject_value == '192.0.2.1'
@pytest.mark.asyncio
async def test_screenshot_redirects_to_one_login_keep_distinct_subject_artifacts(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, _host: str) -> tuple[str, str]:
return 'https://login.example.net/session', 'reachable'
async def take_screenshot(self, url: str, *, output_path: Path | None = None) -> str:
assert url == 'https://login.example.net/session'
assert output_path is not None
output_path.write_bytes(output_path.name.encode()) # noqa: ASYNC240 - tiny in-memory screenshot fixture
return url
def screenshot_path(self, url: str) -> Path:
hostname = url.removeprefix('https://').split('/', maxsplit=1)[0]
return Path(self.output) / f'{hostname}.png'
class TwoHostSource:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'first.example.test', 'second.example.test'}
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', TwoHostSource)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
result = await theharvester_main.start(
EnumerationOptions(
domain='example.test',
screenshot=str(tmp_path),
quiet=True,
source='crtsh',
),
return_completed_result=True,
)
execution = next(item for item in result[-1].active_evidence.executions if item.action == 'screenshot')
assert [(artifact.subject_value, Path(artifact.path).name) for artifact in execution.artifacts] == [
('first.example.test', 'first.example.test.png'),
('second.example.test', 'second.example.test.png'),
]
@pytest.mark.asyncio
async def test_screenshot_cancellation_persists_failed_execution_and_propagates(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
saved: list[CompletedResult] = []
captured = asyncio.Event()
class RecordingResultStore(_NoopResultStore):
async def save_run(self, result: CompletedResult) -> None:
saved.append(result)
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, host: str) -> tuple[str, str]:
return f'https://{host}', 'reachable'
async def take_screenshot(self, host: str, *, output_path: Path | None = None) -> str:
if 'first.' in host:
(output_path or self.screenshot_path(host)).write_bytes(b'png')
captured.set()
return host
await asyncio.Event().wait()
return ''
def screenshot_path(self, host: str) -> Path:
return Path(self.output) / f'{host.removeprefix("https://")}.png'
class TwoHostSource:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'first.example.test', 'second.example.test'}
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
monkeypatch.setattr(theharvester_main, 'ResultStore', RecordingResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', TwoHostSource)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
task = asyncio.create_task(
theharvester_main.start(
EnumerationOptions(
domain='example.test',
screenshot=str(tmp_path),
quiet=True,
source='crtsh',
),
return_completed_result=True,
)
)
await captured.wait()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
execution = next(item for item in saved[-1].active_evidence.executions if item.action == 'screenshot')
assert execution.status == 'partial'
assert execution.stop_reason == 'cancelled'
assert [artifact.subject_value for artifact in execution.artifacts] == ['first.example.test']
@pytest.mark.asyncio
async def test_screenshot_capture_failure_cancels_sibling_tasks(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
sibling_cancelled = asyncio.Event()
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, host: str) -> tuple[str, str]:
return f'https://{host}', 'reachable'
async def take_screenshot(self, url: str, *, output_path: Path | None = None) -> str:
if 'first.' in url:
raise RuntimeError('capture failed')
try:
await asyncio.Event().wait()
finally:
sibling_cancelled.set()
return ''
def screenshot_path(self, url: str) -> Path:
return Path(self.output) / f'{url.removeprefix("https://")}.png'
class TwoHostSource:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'first.example.test', 'second.example.test'}
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self):
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', TwoHostSource)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
result = await theharvester_main.start(
EnumerationOptions(
domain='example.test',
screenshot=str(tmp_path),
quiet=True,
source='crtsh',
),
return_completed_result=True,
)
execution = next(item for item in result[-1].active_evidence.executions if item.action == 'screenshot')
assert execution.status == 'failed'
assert sibling_cancelled.is_set()
@pytest.mark.asyncio
async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
from theHarvester.lib.shodan_evidence import ShodanHostObservation
class FakeResultStore:
async def initialize(self) -> None:
return None
async def record_observations(self, *_args) -> None:
return None
async def save_run(self, _result: CompletedResult) -> None:
return None
class FakeCrtsh:
def __init__(self, _word: str) -> None:
pass
async def process(self, proxy: bool) -> None:
assert proxy is True
return None
async def get_hostnames(self) -> set[str]:
return {'api.example.com'}
class FakeChecker:
def __init__(self, _hosts: list[str], _nameservers: list[str]) -> None:
pass
async def check(self) -> tuple[list[str], list[str], list[str]]:
return ['api.example.com:192.0.2.10'], ['api.example.com'], ['192.0.2.10']
class FakeTakeOver:
def __init__(self, hosts: list[str]) -> None:
assert hosts == ['api.example.com']
self.request_count = 2
self.request_error_count = 0
self.request_error_types: set[str] = set()
self.scan_error_type = None
async def populate_fingerprints(self) -> None:
return None
async def process(self, proxy: bool = False) -> None:
assert proxy is True
async def get_takeover_results(self) -> dict[str, list[dict[str, str]]]:
return {'https://api.example.com': [{'No such app': 'Heroku'}]}
class FakeScreenShotter:
slash = '/'
def __init__(self, output: str) -> None:
self.output = output
def verify_path(self) -> bool:
return True
async def verify_installation(self) -> None:
return None
async def visit(self, host: str) -> tuple[str, str]:
return host, 'reachable'
@staticmethod
def chunk_list(values: list[str], _size: int) -> list[list[str]]:
return [values]
async def take_screenshot(self, host: str, *, output_path: Path | None = None) -> str:
(output_path or self.screenshot_path(host)).write_bytes(b'png')
return f'https://{host}'
def screenshot_path(self, host: str) -> Path:
return Path(self.output) / f'{host.removeprefix("https://")}.png'
class FakeShodan:
error_type = None
def __init__(self) -> None:
self.attributions: set[AsnAttributionObservation] = set()
self.hosts: dict[str, ShodanHostObservation] = {}
async def search_ip(self, ip: str, *, proxy: bool = False) -> dict[str, dict[str, object]]:
assert proxy is True
self.attributions.add(
AsnAttributionObservation(
'action',
'shodan',
'AS64496',
'Example Transit',
'ip',
ip,
datetime.now(UTC),
)
)
self.hosts[ip] = ShodanHostObservation.from_record(
ip,
{
'asn': 'AS64496',
'organization': 'Example Transit',
'services': [{'port': 443, 'transport': 'tcp', 'product': 'nginx'}],
},
)
return {ip: self.hosts[ip].to_details()}
async def get_asn_attributions(self) -> set[AsnAttributionObservation]:
return self.attributions
async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]:
return tuple(self.hosts.values())
class FakeApiScanner:
def __init__(self, word: str, wordlist: str, exact_paths: bool = False) -> None:
assert word == 'example.com'
assert wordlist == str(tmp_path / 'api.txt')
self.scan_error_type = None
self.request_error_count = 0
self.request_error_types: set[str] = set()
async def do_search(self) -> None:
return None
def get_found_endpoints(self) -> set[str]:
return {'https://example.com/api/v1'}
def get_interesting_endpoints(self) -> set[str]:
return {'https://example.com/api/v1'}
def get_auth_required(self) -> set[str]:
return set()
def get_api_versions(self) -> set[str]:
return {'v1'}
def get_rate_limits(self) -> dict:
return {}
def get_methods(self) -> set[str]:
return {'GET'}
def get_status_codes(self) -> set[int]:
return {200}
class FakePool:
def __init__(self, _workers: int) -> None:
pass
async def __aenter__(self) -> 'FakePool':
return self
async def __aexit__(self, *_args) -> None:
return None
async def map(self, function, values):
return [await function(value) for value in values]
async def no_sleep(_seconds: float) -> None:
return None
wordlist = tmp_path / 'api.txt'
wordlist.write_text('/api/v1\n', encoding='utf-8')
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', FakeCrtsh)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', FakeTakeOver)
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', FakeShodan)
monkeypatch.setattr(theharvester_main.api_endpoints, 'SearchApiEndpoints', FakeApiScanner)
monkeypatch.setattr(theharvester_main, 'Pool', FakePool)
monkeypatch.setattr(theharvester_main.asyncio, 'sleep', no_sleep)
result = await theharvester_main.start(
EnumerationOptions(
api_scan=True,
dns_resolve='192.0.2.53',
domain='example.com',
proxies=True,
quiet=True,
screenshot=str(tmp_path),
shodan=True,
source='crtsh',
take_over=True,
wordlist=str(wordlist),
),
include_breaches=True,
return_completed_result=True,
)
completed = result[-1]
assert isinstance(completed, CompletedResult)
assert ('url', 'https://example.com/api/v1') in completed.results
assert ('screenshot', 'api.example.com') not in completed.results
assert ('shodan-host', '192.0.2.10') in completed.results
assert completed.shodan_hosts[0].to_details() == {
'asn': 'AS64496',
'organization': 'Example Transit',
'services': [{'port': 443, 'transport': 'tcp', 'product': 'nginx'}],
}
assert ('asn', 'AS64496') in completed.results
assert completed.asn_attributions[0].organization_label == 'Example Transit'
takeover_result = (
'takeover',
'{"matches":[{"No such app":"Heroku"}],"url":"https://api.example.com"}',
)
assert takeover_result in completed.results
takeover_execution = next(execution for execution in completed.active_evidence.executions if execution.action == 'takeover')
assert takeover_execution.status == 'completed'
assert takeover_execution.result_count == 1
assert takeover_execution.error_type is None
assert takeover_execution.stop_reason is None
screenshot_execution = next(
execution for execution in completed.active_evidence.executions if execution.action == 'screenshot'
)
assert screenshot_execution.status == 'completed'
assert screenshot_execution.result_count == 0
assert screenshot_execution.artifacts[0].subject_value == 'api.example.com'
shodan_execution = next(execution for execution in completed.active_evidence.executions if execution.action == 'shodan')
assert shodan_execution.status == 'completed'
assert shodan_execution.result_count == 3
assert shodan_execution.error_type is None
assert shodan_execution.stop_reason is None
api_executions = [execution for execution in completed.active_evidence.executions if execution.action == 'api-scan']
assert len(api_executions) == 1
api_execution = api_executions[0]
assert api_execution.status == 'completed'
assert api_execution.result_count == 1
assert api_execution.error_type is None
assert api_execution.stop_reason is None
assert {(observation.kind, observation.value) for observation in api_execution.observations} == {
('url', 'https://example.com/api/v1')
}
@pytest.mark.asyncio
async def test_shodan_source_evidence_is_not_overwritten_by_conflicting_action_evidence(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from theHarvester.lib.shodan_evidence import ShodanHostObservation
class ConflictingShodan:
error_type = None
def __init__(self, word: str | None = None) -> None:
self.word = word
self.host = (
ShodanHostObservation.from_record(
'192.0.2.10',
{'services': [{'port': 53, 'transport': 'udp'}]},
)
if word is not None
else None
)
async def process(self, proxy: bool = False) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'api.example.com'} if self.word is not None else set()
async def search_ip(self, ip: str, *, proxy: bool = False) -> dict[str, dict[str, object]]:
self.host = ShodanHostObservation.from_record(
ip,
{'services': [{'port': 443, 'transport': 'tcp'}]},
)
return {ip: self.host.to_details()}
async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]:
return (self.host,) if self.host is not None else ()
async def get_asn_attributions(self) -> set:
if self.word is not None:
return set()
return {
AsnAttributionObservation(
'action',
'shodan',
'AS64496',
'Example Transit',
'ip',
'192.0.2.10',
datetime.now(UTC),
)
}
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', _ApiHostChecker)
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', ConflictingShodan)
result = await theharvester_main.start(
EnumerationOptions(
dns_resolve='192.0.2.53',
domain='example.com',
quiet=True,
shodan=True,
source='shodan',
),
return_completed_result=True,
)
completed = result[-1]
assert completed.shodan_hosts[0].to_details() == {'services': [{'port': 53, 'transport': 'udp'}]}
assert ResultObservation('shodan', 'shodan-host', '192.0.2.10') in completed.observations
execution = next(item for item in completed.active_evidence.executions if item.action == 'shodan')
assert execution.status == 'partial'
assert execution.error_type == 'ValueError'
assert {(observation.kind, observation.value) for observation in execution.observations} == {
('asn', 'AS64496'),
('ip', '192.0.2.10'),
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
('request_count', 'request_errors', 'scan_error', 'expected_status', 'expected_error', 'expected_reason'),
[
(2, 1, None, 'partial', 'TransportError', 'request-errors'),
(2, 2, None, 'failed', 'TransportError', 'request-errors'),
(0, 0, 'RuntimeError', 'failed', 'RuntimeError', 'scan-error'),
],
)
async def test_takeover_action_records_suppressed_outcome(
monkeypatch: pytest.MonkeyPatch,
request_count: int,
request_errors: int,
scan_error: str | None,
expected_status: str,
expected_error: str,
expected_reason: str,
) -> None:
class FakeTakeOver:
def __init__(self, _hosts: list[str]) -> None:
self.request_count = request_count
self.request_error_count = request_errors
self.request_error_types = {'TransportError'} if request_errors else set()
self.scan_error_type = scan_error
async def populate_fingerprints(self) -> None:
return None
async def process(self, proxy: bool = False) -> None:
assert proxy is False
return None
async def get_takeover_results(self) -> dict:
return {}
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource)
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', FakeTakeOver)
result = await theharvester_main.start(
EnumerationOptions(domain='example.com', quiet=True, source='crtsh', take_over=True),
return_completed_result=True,
)
execution = next(item for item in result[-1].active_evidence.executions if item.action == 'takeover')
assert execution.status == expected_status
assert execution.result_count == 0
assert execution.error_type == expected_error
assert execution.stop_reason == expected_reason
@pytest.mark.asyncio
async def test_shodan_action_records_all_target_errors_as_failed(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.INFO)
class FailedShodan:
error_type = None
async def search_ip(self, ip: str, *, proxy: bool = False) -> dict[str, str]:
assert proxy is False
raise RuntimeError(f'provider-secret-payload for {ip}')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', _ApiHostChecker)
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', FailedShodan)
result = await theharvester_main.start(
EnumerationOptions(
dns_resolve='192.0.2.53',
domain='example.com',
quiet=True,
shodan=True,
source='crtsh',
),
return_completed_result=True,
)
completed = result[-1]
shodan_execution = next(execution for execution in completed.active_evidence.executions if execution.action == 'shodan')
assert shodan_execution.status == 'failed'
assert shodan_execution.result_count == 0
assert shodan_execution.error_type == 'RuntimeError'
assert shodan_execution.stop_reason == 'target-errors'
assert 'provider-secret-payload' not in caplog.text
@pytest.mark.asyncio
async def test_shodan_no_data_is_a_completed_zero_yield_action(monkeypatch: pytest.MonkeyPatch) -> None:
class EmptyShodan:
error_type = None
async def search_ip(self, _ip: str, *, proxy: bool = False) -> dict:
assert proxy is False
return {}
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', _ApiHostChecker)
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', EmptyShodan)
result = await theharvester_main.start(
EnumerationOptions(dns_resolve='192.0.2.53', domain='example.com', quiet=True, shodan=True, source='crtsh'),
return_completed_result=True,
)
completed = result[-1]
execution = next(item for item in completed.active_evidence.executions if item.action == 'shodan')
assert execution.status == 'completed'
assert execution.result_count == 0
assert execution.error_type is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
('scan_error', 'request_errors', 'rate_limited', 'expected_status', 'expected_error', 'expected_reason'),
[
('RuntimeError', 0, False, 'failed', 'RuntimeError', 'scan-error'),
(None, 3, False, 'partial', 'TransportError', 'request-errors'),
(None, 0, True, 'rate-limited', None, 'rate-limited'),
],
)
async def test_api_scan_records_suppressed_scan_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
scan_error: str | None,
request_errors: int,
rate_limited: bool,
expected_status: str,
expected_error: str | None,
expected_reason: str,
) -> None:
class FailedApiScanner:
scan_error_type = scan_error
request_error_count = request_errors
request_error_types = {'TransportError'} if request_errors else set()
def __init__(self, word: str, wordlist: str, exact_paths: bool = False) -> None:
assert word == 'example.com'
assert wordlist == str(tmp_path / 'api.txt')
assert exact_paths is True
async def do_search(self) -> None:
return None
def get_found_endpoints(self) -> dict:
return {}
def get_interesting_endpoints(self) -> dict:
return {}
def get_auth_required(self) -> dict:
return {}
def get_api_versions(self) -> set[str]:
return set()
def get_rate_limits(self) -> dict:
if rate_limited:
return {'/api': type('RateLimitInfo', (), {'method': 'GET'})()}
return {}
def get_methods(self) -> set[str]:
return set()
def get_status_codes(self) -> set[int]:
return set()
wordlist = tmp_path / 'api.txt'
wordlist.write_text('/api\n', encoding='utf-8')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.api_endpoints, 'SearchApiEndpoints', FailedApiScanner)
result = await theharvester_main.start(
EnumerationOptions(
api_scan=True,
domain='example.com',
quiet=True,
wordlist=str(wordlist),
),
return_completed_result=True,
)
completed = result[-1]
api_execution = next(execution for execution in completed.active_evidence.executions if execution.action == 'api-scan')
assert api_execution.status == expected_status
assert api_execution.result_count == 0
assert api_execution.error_type == expected_error
assert api_execution.stop_reason == expected_reason
@pytest.mark.asyncio
async def test_takeover_without_hosts_is_skipped_without_starting(monkeypatch: pytest.MonkeyPatch) -> None:
class UnexpectedTakeOver:
def __init__(self, _hosts: list[str]) -> None:
raise AssertionError('takeover should not start without hosts')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', UnexpectedTakeOver)
result = await theharvester_main.start(
EnumerationOptions(domain='example.com', quiet=True, take_over=True),
return_completed_result=True,
)
completed = result[-1]
takeover_execution = next(execution for execution in completed.active_evidence.executions if execution.action == 'takeover')
assert takeover_execution.status == 'skipped'
assert takeover_execution.result_count == 0
assert takeover_execution.stop_reason == 'no-input'
@pytest.mark.asyncio
async def test_shodan_without_ips_is_skipped_without_starting(monkeypatch: pytest.MonkeyPatch) -> None:
class UnexpectedShodan:
def __init__(self) -> None:
raise AssertionError('Shodan should not start without IP addresses')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', UnexpectedShodan)
result = await theharvester_main.start(
EnumerationOptions(domain='example.com', quiet=True, shodan=True),
return_completed_result=True,
)
completed = result[-1]
shodan_execution = next(execution for execution in completed.active_evidence.executions if execution.action == 'shodan')
assert shodan_execution.status == 'skipped'
assert shodan_execution.result_count == 0
assert shodan_execution.stop_reason == 'no-input'
@pytest.mark.asyncio
async def test_routeviews_persists_typed_network_evidence_for_explicit_ip_target(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[tuple[object, ...], tuple[str, ...], str | None]] = []
async def fake_routeviews(asns, network_seeds, *, api_key: str | None = None) -> RouteViewsResult:
calls.append((tuple(asns), tuple(network_seeds), api_key))
collected_at = datetime.now(UTC)
origin = PrefixOriginObservation('routeviews', '192.0.2.0/24', 'AS64500', collected_at)
rpki = RpkiValidationObservation(
'routeviews',
'192.0.2.0/24',
'AS64500',
'valid',
collected_at,
collected_at,
)
return RouteViewsResult(
prefixes=('192.0.2.0/24',),
origin_asns=('AS64500',),
observations=(origin, rpki),
request_count=1,
error_count=0,
status='completed',
)
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'enrich_routeviews', fake_routeviews)
monkeypatch.setattr(theharvester_main.Core, 'routeviews_key', staticmethod(lambda: 'routeviews-key'))
result = await theharvester_main.start(
EnumerationOptions(domain='192.0.2.7', quiet=True, routeviews=True),
return_completed_result=True,
)
completed = result[-1]
assert calls == [((), ('192.0.2.7',), 'routeviews-key')]
assert {value for kind, value in completed.results if kind == 'prefix'} == {'192.0.2.0/24'}
assert {value for kind, value in completed.results if kind == 'asn'} == {'AS64500'}
assert len(completed.network_observations) == 2
assert isinstance(completed.network_observations[0], PrefixOriginObservation)
assert isinstance(completed.network_observations[1], RpkiValidationObservation)
execution = next(item for item in completed.active_evidence.executions if item.action == 'routeviews')
assert execution.status == 'completed'
assert {(item.kind, item.value) for item in execution.observations} == {
('asn', 'AS64500'),
('prefix', '192.0.2.0/24'),
}
@pytest.mark.asyncio
async def test_routeviews_pivots_from_an_explicit_asn_target(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[tuple[object, ...], tuple[str, ...]]] = []
async def fake_routeviews(asns, network_seeds, *, api_key: str | None = None) -> RouteViewsResult:
assert api_key is None
calls.append((tuple(asns), tuple(network_seeds)))
return RouteViewsResult((), (), (), 2, 0, 'completed', stop_reason='no-results')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main, 'enrich_routeviews', fake_routeviews)
monkeypatch.setattr(theharvester_main.Core, 'routeviews_key', staticmethod(lambda: None))
result = await theharvester_main.start(
EnumerationOptions(domain='as64500', quiet=True, routeviews=True),
return_completed_result=True,
)
assert calls == [(('AS64500',), ())]
assert result[-1].target == 'AS64500'
execution = next(item for item in result[-1].active_evidence.executions if item.action == 'routeviews')
assert execution.status == 'completed'
assert execution.stop_reason == 'no-results'
@pytest.mark.asyncio
async def test_explicit_asn_target_rejects_discovery_sources(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
with pytest.raises(ValueError, match='ASN target requires --routeviews without discovery sources or other actions'):
await theharvester_main.start(
EnumerationOptions(domain='AS64500', quiet=True, routeviews=True, source='crtsh'),
return_completed_result=True,
)
@pytest.mark.asyncio
async def test_routeviews_hostname_target_requires_a_discovery_source(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
with pytest.raises(ValueError, match='RouteViews hostname target requires a discovery source'):
await theharvester_main.start(
EnumerationOptions(domain='api.example.com', quiet=True, routeviews=True),
return_completed_result=True,
)
@pytest.mark.asyncio
async def test_routeviews_pivots_from_attributed_ips_without_expanding_discovered_asns(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[tuple[tuple[object, ...], tuple[str, ...]]] = []
class FakeUrlscan:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
raise AssertionError('hostname results must not be retrieved')
async def get_ips(self) -> set[str]:
return {'192.0.2.10'}
async def get_asns(self) -> set[str]:
return {'AS64500'}
async def get_urls(self) -> set[str]:
return set()
async def get_asn_attributions(self) -> set[AsnAttributionObservation]:
return {
AsnAttributionObservation(
'source',
'urlscan',
'AS64500',
'Example Transit',
'ip',
'192.0.2.10',
datetime.now(UTC),
),
AsnAttributionObservation(
'source',
'urlscan',
'AS64500',
'Example Transit',
'hostname',
'example.com',
datetime.now(UTC),
),
}
async def fake_routeviews(asns, network_seeds, *, api_key: str | None = None) -> RouteViewsResult:
assert api_key is None
calls.append((tuple(asns), tuple(network_seeds)))
return RouteViewsResult((), (), (), 2, 0, 'completed', stop_reason='no-results')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.urlscan, 'SearchUrlscan', FakeUrlscan)
monkeypatch.setattr(theharvester_main, 'enrich_routeviews', fake_routeviews)
monkeypatch.setattr(theharvester_main.Core, 'routeviews_key', staticmethod(lambda: None))
result = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
quiet=True,
routeviews=True,
source='urlscan',
proxies=True,
no_hosts=True,
),
return_completed_result=True,
)
assert calls == [((), ('192.0.2.10',))]
completed = result[-1]
assert ('asn', 'AS64500') in completed.results
assert ('ip', '192.0.2.10') in completed.results
assert not any(kind == 'hostname' for kind, _value in completed.results)
assert len(completed.asn_attributions) == 1
assert completed.asn_attributions[0].subject_value == '192.0.2.10'
execution = next(item for item in result[-1].active_evidence.executions if item.action == 'routeviews')
assert execution.status == 'completed'
assert execution.stop_reason == 'no-results'
@pytest.mark.asyncio
async def test_routeviews_cancellation_persists_partial_network_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
saved: list[CompletedResult] = []
async def cancel_routeviews(asns, network_seeds, *, api_key: str | None = None) -> RouteViewsResult:
assert tuple(asns) == ()
assert tuple(network_seeds) == ('192.0.2.7',)
assert api_key is None
collected_at = datetime.now(UTC)
origin = PrefixOriginObservation('routeviews', '192.0.2.0/24', 'AS64500', collected_at)
raise RouteViewsCancelled(
RouteViewsResult(
prefixes=('192.0.2.0/24',),
origin_asns=('AS64500',),
observations=(origin,),
request_count=1,
error_count=1,
status='partial',
error_type='CancelledError',
stop_reason='cancelled',
)
)
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main, 'enrich_routeviews', cancel_routeviews)
monkeypatch.setattr(theharvester_main.Core, 'routeviews_key', staticmethod(lambda: None))
with pytest.raises(RouteViewsCancelled):
await theharvester_main.start(
EnumerationOptions(domain='192.0.2.7', quiet=True, routeviews=True),
return_completed_result=True,
)
execution = next(item for item in saved[-1].active_evidence.executions if item.action == 'routeviews')
assert execution.status == 'partial'
assert execution.error_type == 'CancelledError'
assert execution.stop_reason == 'cancelled'
assert ('prefix', '192.0.2.0/24') in saved[-1].results
@pytest.mark.asyncio
async def test_routeviews_cancellation_persists_when_the_checkpoint_fails(monkeypatch: pytest.MonkeyPatch) -> None:
saved: list[CompletedResult] = []
async def cancel_routeviews(asns, network_seeds, *, api_key: str | None = None) -> RouteViewsResult:
assert api_key is None
collected_at = datetime.now(UTC)
origin = PrefixOriginObservation('routeviews', '192.0.2.0/24', 'AS64500', collected_at)
raise RouteViewsCancelled(
RouteViewsResult(
prefixes=('192.0.2.0/24',),
origin_asns=('AS64500',),
observations=(origin,),
request_count=1,
error_count=1,
status='partial',
error_type='CancelledError',
stop_reason='cancelled',
)
)
async def failed_checkpoint(result: CompletedResult) -> None:
if any(execution.action == 'routeviews' for execution in result.active_evidence.executions):
raise RuntimeError('checkpoint failed')
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main, 'enrich_routeviews', cancel_routeviews)
monkeypatch.setattr(theharvester_main.Core, 'routeviews_key', staticmethod(lambda: None))
with pytest.raises(RouteViewsCancelled):
await theharvester_main.start(
EnumerationOptions(domain='192.0.2.7', quiet=True, routeviews=True),
completed_result_checkpoint=failed_checkpoint,
return_completed_result=True,
)
execution = next(item for item in saved[-1].active_evidence.executions if item.action == 'routeviews')
assert execution.stop_reason == 'cancelled'
assert ('prefix', '192.0.2.0/24') in saved[-1].results
@pytest.mark.asyncio
async def test_api_scan_cancellation_persists_failure_and_propagates(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
saved: list[CompletedResult] = []
class CancelledApiScanner:
def __init__(self, word: str, wordlist: str, exact_paths: bool = False) -> None:
assert word == 'example.com'
assert wordlist == str(tmp_path / 'api.txt')
assert exact_paths is True
async def do_search(self) -> None:
raise asyncio.CancelledError
def get_found_endpoints(self) -> set[str]:
return {'https://example.com/api/v1'}
def get_interesting_endpoints(self) -> set[str]:
return {'https://example.com/api/v1'}
wordlist = tmp_path / 'api.txt'
wordlist.write_text('/api\n', encoding='utf-8')
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main.api_endpoints, 'SearchApiEndpoints', CancelledApiScanner)
with pytest.raises(asyncio.CancelledError):
await theharvester_main.start(
EnumerationOptions(
api_scan=True,
domain='example.com',
quiet=True,
wordlist=str(wordlist),
),
return_completed_result=True,
)
execution = next(item for item in saved[-1].active_evidence.executions if item.action == 'api-scan')
assert execution.status == 'partial'
assert execution.result_count == 1
assert execution.error_type == 'CancelledError'
assert execution.stop_reason == 'cancelled'
assert {(observation.kind, observation.value) for observation in execution.observations} == {
('url', 'https://example.com/api/v1')
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
('raised_error', 'failure_stage', 'expected_status', 'expected_count'),
[
(RuntimeError('scan failed'), 'search', 'partial', 1),
(MissingKey('API endpoints'), 'init', 'failed', 0),
(RuntimeError('getter failed'), 'getter', 'partial', 1),
],
)
async def test_api_scan_raised_failure_is_persisted(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
raised_error: Exception,
failure_stage: str,
expected_status: str,
expected_count: int,
) -> None:
class FailedApiScanner:
def __init__(self, word: str, wordlist: str, exact_paths: bool = False) -> None:
assert exact_paths is True
assert word == 'example.com'
assert wordlist == str(tmp_path / 'api.txt')
self.scan_error_type = None
self.request_error_count = 0
self.request_error_types: set[str] = set()
if failure_stage == 'init':
raise raised_error
async def do_search(self) -> None:
if failure_stage == 'search':
raise raised_error
def get_found_endpoints(self) -> set[str]:
if failure_stage == 'getter':
raise raised_error
return {'https://example.com/api/v1'}
def get_interesting_endpoints(self) -> set[str]:
return {'https://example.com/api/v1'}
def get_auth_required(self) -> dict:
return {}
def get_api_versions(self) -> set[str]:
return set()
def get_rate_limits(self) -> dict:
return {}
def get_methods(self) -> set[str]:
return set()
def get_status_codes(self) -> set[int]:
return set()
wordlist = tmp_path / 'api.txt'
wordlist.write_text('/api\n', encoding='utf-8')
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
monkeypatch.setattr(theharvester_main.api_endpoints, 'SearchApiEndpoints', FailedApiScanner)
result = await theharvester_main.start(
EnumerationOptions(api_scan=True, domain='example.com', quiet=True, wordlist=str(wordlist)),
return_completed_result=True,
)
executions = [item for item in result[-1].active_evidence.executions if item.action == 'api-scan']
assert len(executions) == 1
execution = executions[0]
assert execution.status == expected_status
assert execution.result_count == expected_count
assert execution.error_type == type(raised_error).__name__
assert execution.stop_reason == 'scan-error'
if failure_stage == 'getter':
assert {(observation.kind, observation.value) for observation in execution.observations} == {
('url', 'https://example.com/api/v1')
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
('raised_error', 'failure_stage', 'expected_reason'),
[
(asyncio.CancelledError(), 'process', 'cancelled'),
(RuntimeError('scan failed'), 'process', 'scan-error'),
(RuntimeError('constructor failed'), 'init', 'scan-error'),
(RuntimeError('getter failed'), 'getter', 'scan-error'),
],
)
async def test_takeover_failure_persists_and_propagates(
monkeypatch: pytest.MonkeyPatch,
raised_error: BaseException,
failure_stage: str,
expected_reason: str,
) -> None:
saved: list[CompletedResult] = []
class CancelledTakeOver:
def __init__(self, _hosts: list[str]) -> None:
self.request_error_count = 0
self.request_error_types: set[str] = set()
self.scan_error_type = None
if failure_stage == 'init':
raise raised_error
async def populate_fingerprints(self) -> None:
return None
async def process(self, _proxy: bool = False, **_kwargs) -> None:
if failure_stage == 'process':
raise raised_error
async def get_takeover_results(self) -> dict:
if failure_stage == 'getter':
raise raised_error
return {}
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource)
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', CancelledTakeOver)
with pytest.raises(type(raised_error)):
await theharvester_main.start(
EnumerationOptions(domain='example.com', quiet=True, source='crtsh', take_over=True),
return_completed_result=True,
)
execution = next(item for item in saved[-1].active_evidence.executions if item.action == 'takeover')
assert execution.status == 'failed'
assert execution.result_count == 0
assert execution.error_type == type(raised_error).__name__
assert execution.stop_reason == expected_reason
@pytest.mark.asyncio
async def test_shodan_cancellation_persists_failure_and_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
from theHarvester.lib.shodan_evidence import ShodanHostObservation
saved: list[CompletedResult] = []
class FakeChecker:
def __init__(self, _hosts: list[str], _nameservers: list[str]) -> None:
pass
async def check(self) -> tuple[list[str], list[str], list[str]]:
return (
['api.example.com:192.0.2.10', 'www.example.com:192.0.2.11'],
['api.example.com', 'www.example.com'],
['192.0.2.10', '192.0.2.11'],
)
class CancelledShodan:
error_type = None
def __init__(self) -> None:
self.calls = 0
self.hosts: dict[str, ShodanHostObservation] = {}
async def search_ip(self, ip: str, *, proxy: bool = False) -> dict:
assert proxy is False
self.calls += 1
if self.calls == 2:
raise asyncio.CancelledError
self.hosts[ip] = ShodanHostObservation.from_record(
ip,
{'services': [{'port': 443, 'transport': 'tcp'}]},
)
return {ip: self.hosts[ip].to_details()}
async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]:
return tuple(self.hosts.values())
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', CancelledShodan)
with pytest.raises(asyncio.CancelledError):
await theharvester_main.start(
EnumerationOptions(
dns_resolve='192.0.2.53',
domain='example.com',
quiet=True,
shodan=True,
source='crtsh',
),
return_completed_result=True,
)
execution = next(item for item in saved[-1].active_evidence.executions if item.action == 'shodan')
assert execution.status == 'partial'
assert execution.result_count == 1
assert execution.error_type == 'CancelledError'
assert execution.stop_reason == 'cancelled'
@pytest.mark.asyncio
@pytest.mark.parametrize('action', ['takeover', 'shodan'])
async def test_direct_action_checkpoint_cancellation_persists_and_propagates(
monkeypatch: pytest.MonkeyPatch, action: str
) -> None:
from theHarvester.lib.shodan_evidence import ShodanHostObservation
checkpoints: list[CompletedResult] = []
saved: list[CompletedResult] = []
class FakeTakeOver:
def __init__(self, _hosts: list[str]) -> None:
self.request_count = 1
self.request_error_count = 0
self.request_error_types: set[str] = set()
self.scan_error_type = None
async def populate_fingerprints(self) -> None:
return None
async def process(self, proxy: bool = False) -> None:
assert proxy is False
async def get_takeover_results(self) -> dict:
return {}
class FakeShodan:
error_type = None
def __init__(self) -> None:
self.hosts: dict[str, ShodanHostObservation] = {}
async def search_ip(self, ip: str, *, proxy: bool = False) -> dict:
assert proxy is False
self.hosts[ip] = ShodanHostObservation.from_record(
ip,
{'services': [{'port': 443, 'transport': 'tcp'}]},
)
return {ip: self.hosts[ip].to_details()}
async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]:
return tuple(self.hosts.values())
async def cancel_after_action(result: CompletedResult) -> None:
if any(execution.action == action for execution in result.active_evidence.executions):
checkpoints.append(result)
raise asyncio.CancelledError
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource)
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', FakeTakeOver)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', _ApiHostChecker)
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', FakeShodan)
options = EnumerationOptions(
dns_resolve='192.0.2.53' if action == 'shodan' else '',
domain='example.com',
quiet=True,
shodan=action == 'shodan',
source='crtsh',
take_over=action == 'takeover',
)
with pytest.raises(asyncio.CancelledError):
await theharvester_main.start(
options,
completed_result_checkpoint=cancel_after_action,
return_completed_result=True,
)
execution = next(item for item in saved[-1].active_evidence.executions if item.action == action)
assert execution.status == 'completed'
assert saved[-1] == checkpoints[-1]
@pytest.mark.asyncio
async def test_recursive_dns_results_reach_completed_output_without_changing_legacy_shapes(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
completed: list[CompletedResult] = []
captured: list[tuple[str, tuple[str, ...], int, int]] = []
closed: list[str] = []
output_path = tmp_path / 'recursive-dns'
class FakeResultStore:
async def initialize(self) -> None:
return None
async def record_observations(self, *_args) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class FakeCrtsh:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'api.example.com'}
class FakeChecker:
def __init__(self, _hosts: list[str], _nameservers: list[str]) -> None:
pass
async def check(self) -> tuple[list[str], list[str], list[str]]:
return ['api.example.com:192.0.2.1'], ['api.example.com'], ['192.0.2.1']
class FakeResolver:
def __init__(self, nameserver: str, target: str) -> None:
self.name = nameserver
assert target == 'example.com'
async def close(self) -> None:
closed.append(self.name)
async def fake_recursive(target, seeds, _labels, _resolvers, limits):
captured.append((target, tuple(seeds), limits.depth, limits.query_limit))
return RecursiveDNSResult(
findings=(
RecursiveDNSFinding(
'dev.api.example.com',
'api.example.com',
HostDnsRecords(ipv4=('192.0.2.2',), ipv6=('2001:db8::2',)),
('ptr.example.net',),
),
),
query_count=24,
depth_reached=1,
zero_yield_batches=0,
stop_reason='depth-limit',
classifications=(
RecursiveDNSClassification(
'unused.api.example.com',
'api.example.com',
Addressability.NOT_CURRENT,
HostDnsRecords(cnames=('missing.vendor.test',)),
('legacy-ptr.example.net',),
),
),
)
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', FakeCrtsh)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
monkeypatch.setattr(theharvester_main, 'AioDNSResolverVantage', FakeResolver)
monkeypatch.setattr(theharvester_main, 'discover_recursive_dns', fake_recursive)
monkeypatch.setattr(
sys,
'argv',
[
'theHarvester',
'-d',
'example.com',
'-b',
'crtsh',
'-r',
'192.0.2.53,192.0.2.54,192.0.2.55',
'--dns-recursive-depth',
'1',
'-f',
str(output_path),
],
)
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
assert exit_info.value.code == 0
assert captured == [('example.com', ('api.example.com',), 1, 3_000)]
assert sorted(closed) == ['192.0.2.53', '192.0.2.54', '192.0.2.55']
assert completed
assert ('hostname', 'dev.api.example.com') in completed[0].results
assert ('ip', '192.0.2.2') in completed[0].results
assert ('ip', '2001:db8::2') in completed[0].results
assert (
'dns-recursive-finding',
json.dumps(
{
'addresses': ['192.0.2.2', '2001:db8::2'],
'hostname': 'dev.api.example.com',
'parent': 'api.example.com',
'ptrs': ['ptr.example.net'],
},
separators=(',', ':'),
sort_keys=True,
),
) in completed[0].results
assert set(json.loads(output_path.with_suffix('.json').read_text())['hosts']) >= {
'dev.api.example.com:192.0.2.2',
'dev.api.example.com:2001:db8::2',
}
xml_pairs = [
(element.findtext('hostname'), element.findtext('ip'))
for element in ElementTree.parse(output_path.with_suffix('.xml')).getroot().findall('host')
]
assert xml_pairs.count(('dev.api.example.com', '192.0.2.2')) == 1
assert xml_pairs.count(('dev.api.example.com', '2001:db8::2')) == 1
assert not any(ip is not None and ',' in ip for _host, ip in xml_pairs)
assert (
'dns-recursive-summary',
json.dumps(
{'depth_reached': 1, 'query_count': 24, 'stop_reason': 'depth-limit', 'zero_yield_batches': 0},
separators=(',', ':'),
sort_keys=True,
),
) in completed[0].results
assert (
'dns-recursive-classification',
json.dumps(
{
'addressability': 'not-currently-addressable',
'addresses': [],
'cnames': ['missing.vendor.test'],
'hostname': 'unused.api.example.com',
'parent': 'api.example.com',
'ptrs': ['legacy-ptr.example.net'],
},
separators=(',', ':'),
sort_keys=True,
),
) in completed[0].results
recursive_execution = next(
execution for execution in completed[0].active_evidence.executions if execution.action == 'dns-recursive'
)
assert recursive_execution.status == 'completed'
assert recursive_execution.stop_reason == 'depth-limit'
assert recursive_execution.result_count == 6
assert {(observation.kind, observation.value) for observation in recursive_execution.observations} >= {
('hostname', 'dev.api.example.com'),
('ip', '192.0.2.2'),
('ip', '2001:db8::2'),
}
@pytest.mark.asyncio
async def test_requested_recursive_dns_without_seed_hosts_is_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
class FakeResultStore:
async def initialize(self) -> None:
return None
async def save_run(self, _result: CompletedResult) -> None:
return None
async def unexpected_recursive(*_args, **_kwargs):
raise AssertionError('recursive discovery must not start without seed hostnames')
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main, 'discover_recursive_dns', unexpected_recursive)
response = await theharvester_main.start(
EnumerationOptions(
domain='example.com',
source='',
dns_resolve='192.0.2.53,192.0.2.54,192.0.2.55',
dns_recursive_depth=1,
quiet=True,
),
return_completed_result=True,
)
completed = response[-1]
assert isinstance(completed, CompletedResult)
recursive_execution = next(
execution for execution in completed.active_evidence.executions if execution.action == 'dns-recursive'
)
assert recursive_execution.status == 'skipped'
assert recursive_execution.result_count == 0
assert recursive_execution.stop_reason == 'no-input'
@pytest.mark.parametrize('error_type', [RuntimeError, asyncio.CancelledError])
@pytest.mark.asyncio
async def test_recursive_dns_closes_resolvers_on_failure_and_preserves_cancellation(
monkeypatch: pytest.MonkeyPatch, error_type: type[BaseException]
) -> None:
closed: list[str] = []
completed: list[CompletedResult] = []
class FakeResultStore:
async def initialize(self) -> None:
return None
async def record_observations(self, *_args) -> None:
return None
async def save_run(self, result: CompletedResult) -> None:
completed.append(result)
class FakeCrtsh:
def __init__(self, _word: str) -> None:
pass
async def process(self, _proxy: bool) -> None:
return None
async def get_hostnames(self) -> set[str]:
return {'api.example.com'}
class FakeChecker:
def __init__(self, _hosts: list[str], _nameservers: list[str]) -> None:
pass
async def check(self) -> tuple[list[str], list[str], list[str]]:
return ['api.example.com:192.0.2.1'], ['api.example.com'], ['192.0.2.1']
class FakeResolver:
def __init__(self, nameserver: str, _target: str) -> None:
self.name = nameserver
async def close(self) -> None:
closed.append(self.name)
async def fail_recursive(*_args, **_kwargs):
raise error_type()
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', FakeCrtsh)
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
monkeypatch.setattr(theharvester_main, 'AioDNSResolverVantage', FakeResolver)
monkeypatch.setattr(theharvester_main, 'discover_recursive_dns', fail_recursive)
monkeypatch.setattr(
sys,
'argv',
[
'theHarvester',
'-d',
'example.com',
'-b',
'crtsh',
'-r',
'192.0.2.53,192.0.2.54,192.0.2.55',
'--dns-recursive-depth',
'1',
],
)
if issubclass(error_type, asyncio.CancelledError):
with pytest.raises(asyncio.CancelledError):
await theharvester_main.start()
else:
with pytest.raises(SystemExit) as exit_info:
await theharvester_main.start()
assert exit_info.value.code == 0
assert sorted(closed) == ['192.0.2.53', '192.0.2.54', '192.0.2.55']
assert len(completed) == 1
recursive_execution = next(
execution for execution in completed[0].active_evidence.executions if execution.action == 'dns-recursive'
)
assert recursive_execution.status == 'failed'
assert recursive_execution.error_type == error_type.__name__
assert recursive_execution.stop_reason == ('cancelled' if issubclass(error_type, asyncio.CancelledError) else None)