mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-17 19:35:40 +02:00
4578 lines
166 KiB
Python
4578 lines
166 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
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
import theHarvester.screenshot.screenshot as screenshot_module
|
|
from theHarvester import __main__ as theharvester_main
|
|
from theHarvester.discovery.constants import MissingKey
|
|
from theHarvester.lib import source_runner
|
|
from theHarvester.lib.asn_attribution import AsnAttributionObservation
|
|
from theHarvester.lib.completed_result import CompletedResult, ResultObservation, SourceExecution
|
|
from theHarvester.lib.core import FetcherResponse
|
|
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.source_execution import SourceExecutionReport
|
|
from theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome
|
|
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. Takeover fails closed if no '
|
|
'proxy is available.' 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
|
|
assert '-j SOURCE_WORKERS' in help_text
|
|
assert '--source-workers SOURCE_WORKERS' in help_text
|
|
assert 'Indicators are not confirmed takeovers.' 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,
|
|
)
|
|
|
|
|
|
def _takeover_outcome(hostname: str = 'api.example.com') -> TakeoverCandidateOutcome:
|
|
return TakeoverCandidateOutcome.from_record(
|
|
hostname,
|
|
{
|
|
'status': 'indicator',
|
|
'dns': [
|
|
{
|
|
'resolver': '1.1.1.1',
|
|
'cname_chain': ['missing-bucket.s3.amazonaws.com'],
|
|
'terminal_rcode': 'NOERROR',
|
|
}
|
|
],
|
|
'wildcard_dns': [
|
|
{
|
|
'resolver': '1.1.1.1',
|
|
'cname_chain': [],
|
|
'terminal_rcode': 'NXDOMAIN',
|
|
}
|
|
],
|
|
'http': [{'scheme': 'https', 'status': 404}],
|
|
'indicators': [
|
|
{
|
|
'classification': 'vulnerable-indicator',
|
|
'service': 'AWS/S3',
|
|
'rule_id': 'aws-s3',
|
|
'rule_revision': 'takeover-rules-v1',
|
|
'scheme': 'https',
|
|
'matched': ['body:BucketName', 'body:The specified bucket does not exist'],
|
|
}
|
|
],
|
|
'error_types': [],
|
|
},
|
|
)
|
|
|
|
|
|
@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(source_runner.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:
|
|
def __init__(self, _word: str) -> None:
|
|
pass
|
|
|
|
async def process(self, _proxy: bool) -> SourceExecutionReport:
|
|
return SourceExecutionReport('failed', 'invalid-response')
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'crt.example.com'}
|
|
|
|
class FakeChecker:
|
|
calls = 0
|
|
|
|
def __init__(self, hosts: list[str], nameservers: list[str]) -> None:
|
|
assert nameservers == ['192.0.2.53']
|
|
assert hosts == ['api.example.com', 'crt.example.com', 'reported.example.com']
|
|
type(self).calls += 1
|
|
self.query_error_count = 1
|
|
self.query_error_types = {'TimeoutError'}
|
|
|
|
async def check(self) -> tuple[list[str], list[str], list[str]]:
|
|
return (
|
|
[
|
|
'api.example.com:192.0.2.10',
|
|
'crt.example.com:192.0.2.30',
|
|
'reported.example.com:192.0.2.21',
|
|
],
|
|
['api.example.com', 'crt.example.com', 'reported.example.com'],
|
|
['192.0.2.10', '192.0.2.21', '192.0.2.30'],
|
|
)
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(source_runner.rapiddns, 'SearchRapidDns', FakeRapidDNS)
|
|
monkeypatch.setattr(source_runner.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 FakeChecker.calls == 1
|
|
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_provider_resolved_hostname_survives_no_answer_dns_validation(
|
|
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 FakeHackerTarget:
|
|
def __init__(self, _word: str) -> None:
|
|
pass
|
|
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'provider.example.com'}
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return {'192.0.2.20'}
|
|
|
|
class NoAnswerChecker:
|
|
calls = 0
|
|
completed_count = 1
|
|
query_error_count = 0
|
|
query_error_types: set[str] = set()
|
|
stop_reason = None
|
|
|
|
def __init__(self, hosts: list[str], _nameservers: list[str]) -> None:
|
|
assert hosts == ['provider.example.com']
|
|
type(self).calls += 1
|
|
|
|
async def check(self) -> tuple[list[str], list[str], list[str]]:
|
|
return [], [], []
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(source_runner.hackertarget, 'SearchHackerTarget', FakeHackerTarget)
|
|
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', NoAnswerChecker)
|
|
|
|
output_path = tmp_path / 'provider-no-answer'
|
|
monkeypatch.setattr(
|
|
sys,
|
|
'argv',
|
|
[
|
|
'theHarvester',
|
|
'-d',
|
|
'example.com',
|
|
'-b',
|
|
'hackertarget',
|
|
'-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 NoAnswerChecker.calls == 1
|
|
assert json.loads(output_path.with_suffix('.json').read_text())['hosts'] == ['provider.example.com']
|
|
|
|
|
|
@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(source_runner.certspottersearch, 'SearchCertspoter', FakeSource)
|
|
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', FakeSource)
|
|
monkeypatch.setattr(source_runner.shodanct, 'SearchShodanCt', FakeSource)
|
|
monkeypatch.setattr(source_runner.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(source_runner.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(source_runner.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_dns_resolve_budget_stop_retains_partial_evidence(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 LimitedChecker:
|
|
completed_count = 1
|
|
query_error_count = 0
|
|
query_error_types: set[str] = set()
|
|
stop_reason = 'query-limit'
|
|
|
|
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 ['api.example.com:192.0.2.10'], ['api.example.com'], ['192.0.2.10']
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(source_runner.certspottersearch, 'SearchCertspoter', FakeSource)
|
|
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', LimitedChecker)
|
|
|
|
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.status == 'partial'
|
|
assert execution.stop_reason == 'query-limit'
|
|
assert ('ip', '192.0.2.10') in completed.results
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_dns_resolvers_normalizing_empty_fail_closed(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 UnexpectedChecker:
|
|
def __init__(self, *_args, **_kwargs) -> None:
|
|
raise AssertionError('explicit invalid resolvers must not fall back to system DNS')
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(source_runner.certspottersearch, 'SearchCertspoter', FakeSource)
|
|
monkeypatch.setattr(theharvester_main, 'normalize_resolver_addresses', lambda _values: [])
|
|
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', UnexpectedChecker)
|
|
|
|
response = await theharvester_main.start(
|
|
EnumerationOptions(domain='example.com', source='certspotter', dns_resolve='invalid-resolver', quiet=True),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
completed = response[-1]
|
|
assert isinstance(completed, CompletedResult)
|
|
assert ('hostname', 'api.example.com') in completed.results
|
|
execution = completed.active_evidence.executions[0]
|
|
assert execution.status == 'skipped'
|
|
assert execution.stop_reason == 'no-valid-resolvers'
|
|
|
|
|
|
@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, limit: int) -> None:
|
|
assert domain == 'example.com'
|
|
assert limit == 500
|
|
|
|
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(
|
|
ipranges: tuple[str, ...],
|
|
callback,
|
|
nameservers: list[str] | None = None,
|
|
error_types: set[str] | None = None,
|
|
) -> theharvester_main.dnssearch.ReverseDNSResult:
|
|
assert ipranges == ('192.0.2.0/24',)
|
|
assert nameservers is None
|
|
callback('PTR.example.com.')
|
|
return theharvester_main.dnssearch.ReverseDNSResult(254, 254)
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(source_runner.securityscorecard, 'SearchSecurityScorecard', FakeSecurityScorecard)
|
|
monkeypatch.setattr(theharvester_main.dnssearch, 'reverse_ip_ranges', 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_cancellation_persists_partial_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, limit: int) -> None:
|
|
assert limit == 500
|
|
|
|
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(
|
|
ipranges: tuple[str, ...],
|
|
callback,
|
|
nameservers: list[str] | None = None,
|
|
error_types: set[str] | None = None,
|
|
) -> None:
|
|
assert nameservers is None
|
|
assert ipranges == ('192.0.2.0/24', '198.51.100.0/24')
|
|
callback('partial.example.com')
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(source_runner.securityscorecard, 'SearchSecurityScorecard', FakeSecurityScorecard)
|
|
monkeypatch.setattr(theharvester_main.dnssearch, 'reverse_ip_ranges', 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 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', source_runner.projectdiscovery, 'SearchDiscovery'),
|
|
('bevigil', source_runner.bevigil, 'SearchBeVigil'),
|
|
('builtwith', source_runner.builtwith, 'SearchBuiltWith'),
|
|
('hudsonrock', source_runner.hudsonrocksearch, 'SearchHudsonRock'),
|
|
('shodan', source_runner.shodansearch, 'SearchShodan'),
|
|
],
|
|
)
|
|
@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_target_only_source_reports_search_progress_at_the_cli_seam(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
class EmptyBeVigil:
|
|
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()
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return set()
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(source_runner.bevigil, 'SearchBeVigil', EmptyBeVigil)
|
|
monkeypatch.setattr(sys, 'argv', ['theHarvester', '-d', 'example.test', '-b', 'bevigil'])
|
|
|
|
with pytest.raises(SystemExit) as exit_info:
|
|
await theharvester_main.start()
|
|
|
|
assert exit_info.value.code == 0
|
|
assert '[*] Searching Bevigil.' in capsys.readouterr().out
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_progress_waits_for_runner_admission(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
sources = ('certspotter', 'crt-name', 'crtsh', 'dymo')
|
|
requested_workers = 3
|
|
admitted_sources = sources[:requested_workers]
|
|
constructed: list[str] = []
|
|
processing: set[str] = set()
|
|
admitted = asyncio.Event()
|
|
release = asyncio.Event()
|
|
|
|
class GatedSource:
|
|
def __init__(self, source: str) -> None:
|
|
self.source = source
|
|
|
|
async def process(self, _proxy: bool) -> None:
|
|
processing.add(self.source)
|
|
if len(processing) == requested_workers:
|
|
admitted.set()
|
|
await release.wait()
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return set()
|
|
|
|
def factory(source: str) -> source_runner.SourceFactory:
|
|
def create(_request: source_runner.SourceRequest) -> GatedSource:
|
|
constructed.append(source)
|
|
return GatedSource(source)
|
|
|
|
return create
|
|
|
|
for source in sources:
|
|
monkeypatch.setitem(source_runner.SOURCE_FACTORIES, source, factory(source))
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
theharvester_main.configure_logging(verbose=False)
|
|
|
|
task = asyncio.create_task(
|
|
theharvester_main.start(
|
|
EnumerationOptions(domain='example.test', source=','.join(sources), source_workers=requested_workers, quiet=False),
|
|
return_completed_result=True,
|
|
)
|
|
)
|
|
await asyncio.wait_for(admitted.wait(), timeout=1)
|
|
interim_output = capsys.readouterr().out
|
|
release.set()
|
|
await task
|
|
|
|
assert constructed[:requested_workers] == list(admitted_sources)
|
|
assert all(f'[*] Searching {source[0].upper() + source[1:]}.' in interim_output for source in admitted_sources)
|
|
assert '[*] Searching Dymo.' not in interim_output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_completion_reports_verbose_terminal_summary(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
class CompletedCertSpotter:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'api.example.test'}
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(source_runner.certspottersearch, 'SearchCertspoter', lambda _word: CompletedCertSpotter())
|
|
caplog.set_level(logging.INFO, logger='theHarvester.__main__')
|
|
|
|
await theharvester_main.start(
|
|
EnumerationOptions(domain='example.test', source='certspotter', quiet=True),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
assert any(
|
|
message.startswith('Source certspotter finished in ') and message.endswith('s: status=completed; results=1')
|
|
for message in caplog.messages
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('error', 'expected_status', 'expected_error_type', 'expected_stop_reason', 'expected_diagnostic'),
|
|
[
|
|
(
|
|
MissingKey('sensitive-provider-detail'),
|
|
'skipped',
|
|
'MissingKeyError',
|
|
'missing-credentials',
|
|
'[!] Source bevigil skipped: missing credentials.',
|
|
),
|
|
(
|
|
RuntimeError('sensitive-provider-detail'),
|
|
'failed',
|
|
'RuntimeError',
|
|
None,
|
|
'[!] Source bevigil failed: RuntimeError.',
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_target_only_constructor_failure_reports_sanitized_cli_diagnostic(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
error: Exception,
|
|
expected_status: str,
|
|
expected_error_type: str,
|
|
expected_stop_reason: str | None,
|
|
expected_diagnostic: str,
|
|
) -> None:
|
|
saved_results: list[CompletedResult] = []
|
|
|
|
class RecordingResultStore(_NoopResultStore):
|
|
async def save_run(self, result: CompletedResult) -> None:
|
|
saved_results.append(result)
|
|
|
|
class BrokenBeVigil:
|
|
def __init__(self, _word: str) -> None:
|
|
raise error
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', RecordingResultStore)
|
|
monkeypatch.setattr(source_runner.bevigil, 'SearchBeVigil', BrokenBeVigil)
|
|
monkeypatch.setattr(sys, 'argv', ['theHarvester', '-d', 'example.test', '-b', 'bevigil'])
|
|
|
|
with pytest.raises(SystemExit) as exit_info:
|
|
await theharvester_main.start()
|
|
|
|
output = capsys.readouterr().out
|
|
assert exit_info.value.code == 0
|
|
assert expected_diagnostic in output
|
|
assert '[*] Searching Bevigil.' not in output
|
|
assert 'sensitive-provider-detail' not in output
|
|
execution = saved_results[-1].source_executions[0]
|
|
assert execution.status == expected_status
|
|
assert execution.error_type == expected_error_type
|
|
assert execution.stop_reason == expected_stop_reason
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_source_reports_sanitized_cli_diagnostic(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
class PartialBeVigil:
|
|
async def process(self, _proxy: bool) -> None:
|
|
raise RuntimeError('sensitive-provider-detail')
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'partial.example.test'}
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return set()
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(source_runner.bevigil, 'SearchBeVigil', lambda _word: PartialBeVigil())
|
|
theharvester_main.configure_logging(verbose=False)
|
|
|
|
response = await theharvester_main.start(
|
|
EnumerationOptions(domain='example.test', source='bevigil', quiet=False),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
output = capsys.readouterr().out
|
|
assert '[!] Source bevigil partial: RuntimeError.' in output
|
|
assert 'sensitive-provider-detail' not in output
|
|
assert response[-1].source_executions[0].status == 'partial'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_checkpoint_failure_does_not_log_sensitive_exception_text(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
class EmptyBeVigil:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return set()
|
|
|
|
checkpoint_calls = 0
|
|
|
|
async def broken_checkpoint(_result: CompletedResult) -> None:
|
|
nonlocal checkpoint_calls
|
|
checkpoint_calls += 1
|
|
if checkpoint_calls == 1:
|
|
raise RuntimeError('sensitive-checkpoint-detail')
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(source_runner.bevigil, 'SearchBeVigil', lambda _word: EmptyBeVigil())
|
|
theharvester_main.configure_logging(verbose=False)
|
|
|
|
await theharvester_main.start(
|
|
EnumerationOptions(domain='example.test', source='bevigil', quiet=True),
|
|
completed_result_checkpoint=broken_checkpoint,
|
|
return_completed_result=True,
|
|
)
|
|
|
|
output = capsys.readouterr().out
|
|
assert 'An error occurred while committing bevigil: RuntimeError.' in output
|
|
assert 'sensitive-checkpoint-detail' not in output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_cancellation_does_not_announce_never_started_queued_source(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
capsys: pytest.CaptureFixture[str],
|
|
) -> None:
|
|
sources = ('certspotter', 'crt-name', 'crtsh', 'dymo')
|
|
requested_workers = 3
|
|
cancellation = asyncio.CancelledError('source cancelled')
|
|
constructed: list[str] = []
|
|
processing: set[str] = set()
|
|
admitted = asyncio.Event()
|
|
|
|
class CancellingSource:
|
|
def __init__(self, source: str) -> None:
|
|
self.source = source
|
|
|
|
async def process(self, _proxy: bool) -> None:
|
|
processing.add(self.source)
|
|
if len(processing) == requested_workers:
|
|
admitted.set()
|
|
await admitted.wait()
|
|
if self.source == sources[0]:
|
|
raise cancellation
|
|
await asyncio.Event().wait()
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return set()
|
|
|
|
def factory(source: str) -> source_runner.SourceFactory:
|
|
def create(_request: source_runner.SourceRequest) -> CancellingSource:
|
|
constructed.append(source)
|
|
return CancellingSource(source)
|
|
|
|
return create
|
|
|
|
for source in sources:
|
|
monkeypatch.setitem(source_runner.SOURCE_FACTORIES, source, factory(source))
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
theharvester_main.configure_logging(verbose=False)
|
|
|
|
with pytest.raises(asyncio.CancelledError) as raised:
|
|
async with asyncio.timeout(1):
|
|
await theharvester_main.start(
|
|
EnumerationOptions(
|
|
domain='example.test', source=','.join(sources), source_workers=requested_workers, quiet=False
|
|
),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
output = capsys.readouterr().out
|
|
assert raised.value is cancellation
|
|
assert constructed == list(sources[:requested_workers])
|
|
assert '[*] Searching Dymo.' not in output
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('source', 'module', 'constructor_name'),
|
|
[
|
|
('builtwith', source_runner.builtwith, 'SearchBuiltWith'),
|
|
('hudsonrock', source_runner.hudsonrocksearch, 'SearchHudsonRock'),
|
|
('shodan', source_runner.shodansearch, 'SearchShodan'),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_special_source_constructor_failure_uses_runner_outcome(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
source: str,
|
|
module: ModuleType,
|
|
constructor_name: str,
|
|
) -> None:
|
|
class BrokenSource:
|
|
def __init__(self, _word: str) -> None:
|
|
raise RuntimeError('construction failed')
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(module, constructor_name, BrokenSource)
|
|
|
|
response = await theharvester_main.start(
|
|
EnumerationOptions(domain='example.test', source=source, quiet=True),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
completed = response[-1]
|
|
assert isinstance(completed, CompletedResult)
|
|
assert completed.source_executions == (
|
|
SourceExecution(source, 'failed', completed.source_executions[0].duration_ms, 0, 'RuntimeError'),
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('source', 'module', 'constructor_name', 'expected_results'),
|
|
[
|
|
(
|
|
'builtwith',
|
|
source_runner.builtwith,
|
|
'SearchBuiltWith',
|
|
{
|
|
('analytics', 'Plausible'),
|
|
('cms', 'Wagtail'),
|
|
('framework', 'Django'),
|
|
('hostname', 'partial.example.test'),
|
|
('language', 'Python'),
|
|
('server', 'nginx'),
|
|
('url', 'https://partial.example.test'),
|
|
},
|
|
),
|
|
(
|
|
'hudsonrock',
|
|
source_runner.hudsonrocksearch,
|
|
'SearchHudsonRock',
|
|
{
|
|
('email', 'user@example.test'),
|
|
('hostname', 'partial.example.test'),
|
|
('infostealer', '{"email":"user@example.test"}'),
|
|
},
|
|
),
|
|
('shodan', source_runner.shodansearch, 'SearchShodan', {('hostname', 'partial.example.test')}),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_special_source_cancellation_retains_partial_evidence_and_original_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
source: str,
|
|
module: ModuleType,
|
|
constructor_name: str,
|
|
expected_results: set[tuple[str, str]],
|
|
) -> None:
|
|
cancellation = asyncio.CancelledError(f'cancel {source}')
|
|
saved_results: list[CompletedResult] = []
|
|
|
|
class RecordingResultStore(_NoopResultStore):
|
|
async def save_run(self, result: CompletedResult) -> None:
|
|
saved_results.append(result)
|
|
|
|
class CancellingSource:
|
|
def __init__(self, _word: str) -> None:
|
|
pass
|
|
|
|
async def process(self, _proxy: bool) -> None:
|
|
raise cancellation
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'partial.example.test'}
|
|
|
|
async def get_emails(self) -> set[str]:
|
|
return {'user@example.test'}
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return {'https://partial.example.test'}
|
|
|
|
async def get_frameworks(self) -> set[str]:
|
|
return {'Django'}
|
|
|
|
async def get_languages(self) -> set[str]:
|
|
return {'Python'}
|
|
|
|
async def get_servers(self) -> set[str]:
|
|
return {'nginx'}
|
|
|
|
async def get_cms(self) -> set[str]:
|
|
return {'Wagtail'}
|
|
|
|
async def get_analytics(self) -> set[str]:
|
|
return {'Plausible'}
|
|
|
|
async def get_infostealers(self) -> list[dict[str, str]]:
|
|
return [{'email': 'user@example.test'}]
|
|
|
|
async def get_shodan_hosts(self) -> tuple[()]:
|
|
return ()
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', RecordingResultStore)
|
|
monkeypatch.setattr(module, constructor_name, CancellingSource)
|
|
|
|
with pytest.raises(asyncio.CancelledError) as raised:
|
|
await theharvester_main.start(
|
|
EnumerationOptions(domain='example.test', source=source, quiet=True),
|
|
persist_completed_result=True,
|
|
)
|
|
|
|
assert raised.value is cancellation
|
|
assert len(saved_results) == 1
|
|
assert set(saved_results[0].results) == expected_results
|
|
assert saved_results[0].source_executions == (
|
|
SourceExecution(
|
|
source,
|
|
'partial',
|
|
saved_results[0].source_executions[0].duration_ms,
|
|
len(expected_results),
|
|
'CancelledError',
|
|
'cancelled',
|
|
),
|
|
)
|
|
assert {observation.source for observation in saved_results[0].observations} == {source}
|
|
|
|
|
|
@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(source_runner.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(source_runner.builtwith, 'SearchBuiltWith', PausedBuiltWith)
|
|
monkeypatch.setattr(source_runner.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.parametrize(
|
|
('source', 'main_module_name', 'constructor_name'),
|
|
[
|
|
('arquivo', 'arquivo', 'SearchArquivo'),
|
|
('baidu', 'baidusearch', 'SearchBaidu'),
|
|
('brave', 'bravesearch', 'SearchBrave'),
|
|
('censys', 'censysearch', 'SearchCensys'),
|
|
('commoncrawl', 'commoncrawl', 'SearchCommoncrawl'),
|
|
('dehashed', 'search_dehashed', 'SearchDehashed'),
|
|
('github-code', 'githubcode', 'SearchGithubCode'),
|
|
('hunter', 'huntersearch', 'SearchHunter'),
|
|
('mojeek', 'mojeek', 'SearchMojeek'),
|
|
('netlas', 'netlas', 'SearchNetlas'),
|
|
('rocketreach', 'rocketreach', 'SearchRocketReach'),
|
|
('tomba', 'tombasearch', 'SearchTomba'),
|
|
('waybackarchive', 'waybackarchive', 'SearchWaybackarchive'),
|
|
('yahoo', 'yahoosearch', 'SearchYahoo'),
|
|
('zoomeye', 'zoomeyesearch', 'SearchZoomEye'),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_limited_source_orchestration_uses_immutable_runner_request(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
source: str,
|
|
main_module_name: str,
|
|
constructor_name: str,
|
|
) -> None:
|
|
requests: list[source_runner.SourceRequest] = []
|
|
processed_with_proxy: list[bool] = []
|
|
|
|
class FakeAdapter:
|
|
async def process(self, proxy: bool) -> SourceExecutionReport:
|
|
processed_with_proxy.append(proxy)
|
|
return SourceExecutionReport('partial', 'provider-boundary')
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'sub.example.test'}
|
|
|
|
async def get_emails(self) -> set[str]:
|
|
return {'user@example.test'}
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return {'192.0.2.1'}
|
|
|
|
async def get_asns(self) -> set[str]:
|
|
return {'AS64500'}
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return {'https://sub.example.test/evidence'}
|
|
|
|
def runner_factory(request: source_runner.SourceRequest) -> FakeAdapter:
|
|
requests.append(request)
|
|
return FakeAdapter()
|
|
|
|
def legacy_constructor(*_args: object, **_kwargs: object) -> object:
|
|
raise AssertionError('legacy constructor branch was used')
|
|
|
|
legacy_module = ModuleType(f'test_legacy_{main_module_name}')
|
|
setattr(legacy_module, constructor_name, legacy_constructor)
|
|
monkeypatch.setattr(theharvester_main, main_module_name, legacy_module, raising=False)
|
|
monkeypatch.setitem(source_runner.SOURCE_FACTORIES, source, runner_factory)
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
|
|
response = await theharvester_main.start(
|
|
EnumerationOptions(
|
|
domain='example.test',
|
|
source=source,
|
|
limit=37,
|
|
start=11,
|
|
proxies=True,
|
|
quiet=True,
|
|
),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
assert requests == [source_runner.SourceRequest(source, 'example.test', 37, 11, True, True)]
|
|
assert processed_with_proxy == [True]
|
|
execution = response[-1].source_executions[0]
|
|
assert execution.source == source
|
|
assert execution.status == 'partial'
|
|
assert execution.stop_reason == 'provider-boundary'
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('source', 'main_module_name', 'constructor_name'),
|
|
[
|
|
('bevigil', 'bevigil', 'SearchBeVigil'),
|
|
('bufferoverun', 'bufferoverun', 'SearchBufferover'),
|
|
('certspotter', 'certspottersearch', 'SearchCertspoter'),
|
|
('criminalip', 'criminalip', 'SearchCriminalIP'),
|
|
('crt-name', 'crtname', 'SearchCrtName'),
|
|
('crtsh', 'crtsh', 'SearchCrtsh'),
|
|
('dnsdb', 'dnsdb', 'SearchDNSDB'),
|
|
('dnsdumpster', 'search_dnsdumpster', 'SearchDNSDumpster'),
|
|
('dymo', 'dymosearch', 'SearchDymo'),
|
|
('fofa', 'fofa', 'SearchFofa'),
|
|
('fullhunt', 'fullhuntsearch', 'SearchFullHunt'),
|
|
('gitlab', 'gitlabsearch', 'SearchGitlab'),
|
|
('hackertarget', 'hackertarget', 'SearchHackerTarget'),
|
|
('haveibeenpwned', 'haveibeenpwned', 'SearchHaveIBeenPwned'),
|
|
('hibpverified', 'hibpverified', 'SearchHibpVerified'),
|
|
('hunterhow', 'searchhunterhow', 'SearchHunterHow'),
|
|
('intelx', 'intelxsearch', 'SearchIntelx'),
|
|
('leakix', 'leakix', 'SearchLeakix'),
|
|
('leaklookup', 'leaklookup', 'SearchLeakLookup'),
|
|
('onyphe', 'onyphe', 'SearchOnyphe'),
|
|
('otx', 'otxsearch', 'SearchOtx'),
|
|
('pentesttools', 'pentesttools', 'SearchPentestTools'),
|
|
('projectdiscovery', 'projectdiscovery', 'SearchDiscovery'),
|
|
('rapiddns', 'rapiddns', 'SearchRapidDns'),
|
|
('robtex', 'robtex', 'SearchRobtex'),
|
|
('securityscorecard', 'securityscorecard', 'SearchSecurityScorecard'),
|
|
('securityTrails', 'securitytrailssearch', 'SearchSecuritytrail'),
|
|
('sherlockeye', 'sherlockeye', 'SearchSherlockeye'),
|
|
('shodanInternetDB', 'shodan_internetdb', 'SearchShodanInternetDB'),
|
|
('shodanct', 'shodanct', 'SearchShodanCt'),
|
|
('subdomaincenter', 'subdomaincenter', 'SubdomainCenter'),
|
|
('subdomainfinderc99', 'subdomainfinderc99', 'SearchSubdomainfinderc99'),
|
|
('thc', 'thc', 'SearchThc'),
|
|
('urlscan', 'urlscan', 'SearchUrlscan'),
|
|
('virustotal', 'virustotal', 'SearchVirustotal'),
|
|
('whoisxml', 'whoisxml', 'SearchWhoisXML'),
|
|
('windvane', 'windvane', 'SearchWindvane'),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_target_only_source_orchestration_uses_immutable_runner_request(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
source: str,
|
|
main_module_name: str,
|
|
constructor_name: str,
|
|
) -> None:
|
|
requests: list[source_runner.SourceRequest] = []
|
|
processed_with_proxy: list[bool] = []
|
|
|
|
class FakeAdapter:
|
|
async def process(self, proxy: bool) -> SourceExecutionReport:
|
|
processed_with_proxy.append(proxy)
|
|
return SourceExecutionReport('partial', 'provider-boundary')
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'sub.example.test'}
|
|
|
|
async def get_emails(self) -> set[str]:
|
|
return {'user@example.test'}
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return {'192.0.2.1'}
|
|
|
|
async def get_asns(self) -> set[str]:
|
|
return {'AS64500'}
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return {'https://sub.example.test/evidence'}
|
|
|
|
async def get_breach_names(self) -> set[str]:
|
|
return {'Example Breach'}
|
|
|
|
async def get_host_ip_pairs(self) -> set[tuple[str, str]]:
|
|
return set()
|
|
|
|
def runner_factory(request: source_runner.SourceRequest) -> FakeAdapter:
|
|
requests.append(request)
|
|
return FakeAdapter()
|
|
|
|
def legacy_constructor(*_args: object, **_kwargs: object) -> object:
|
|
raise AssertionError('legacy constructor branch was used')
|
|
|
|
legacy_module = ModuleType(f'test_legacy_{main_module_name}')
|
|
setattr(legacy_module, constructor_name, legacy_constructor)
|
|
monkeypatch.setattr(theharvester_main, main_module_name, legacy_module, raising=False)
|
|
monkeypatch.setitem(source_runner.SOURCE_FACTORIES, source, runner_factory)
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
|
|
response = await theharvester_main.start(
|
|
EnumerationOptions(
|
|
domain='example.test',
|
|
source=source,
|
|
limit=37,
|
|
start=11,
|
|
proxies=True,
|
|
quiet=True,
|
|
),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
assert requests == [source_runner.SourceRequest(source, 'example.test', 37, 11, True, True)]
|
|
assert processed_with_proxy == [True]
|
|
execution = response[-1].source_executions[0]
|
|
assert execution.source == source
|
|
assert execution.status == 'partial'
|
|
assert execution.stop_reason == 'provider-boundary'
|
|
|
|
|
|
@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:
|
|
def __init__(self, _word: str) -> None:
|
|
pass
|
|
|
|
async def process(self, _proxy: bool) -> object:
|
|
return {'status': 'typo'}
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return set()
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(source_runner.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
|
|
|
|
|
|
class _FakeScreenshotBatch:
|
|
async def reachable_targets(self, targets: list[str]) -> list[tuple[str, str]]:
|
|
reachable: list[tuple[str, str]] = []
|
|
for subject in targets:
|
|
final_url, status = await self.visit(subject)
|
|
if status:
|
|
reachable.append((subject, final_url))
|
|
return reachable
|
|
|
|
async def capture_targets(self, targets, record) -> None:
|
|
async def capture(subject: str, final_url: str) -> tuple[str, str, Path]:
|
|
output_path = self.screenshot_path(subject)
|
|
return subject, await self.take_screenshot(final_url, output_path=output_path), output_path
|
|
|
|
tasks = [asyncio.create_task(capture(*target)) for target in targets]
|
|
try:
|
|
for task in asyncio.as_completed(tasks):
|
|
await record(*await task)
|
|
except asyncio.CancelledError:
|
|
for task in tasks:
|
|
task.cancel()
|
|
outcomes = await asyncio.gather(*tasks, return_exceptions=True)
|
|
for outcome in outcomes:
|
|
if isinstance(outcome, tuple):
|
|
await record(*outcome)
|
|
raise
|
|
finally:
|
|
for task in tasks:
|
|
task.cancel()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
@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(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
async def visit(self, host: str) -> tuple[str, str]:
|
|
visited.add(host)
|
|
return host, 'https'
|
|
|
|
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'
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', FakeCrtsh)
|
|
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
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
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_worker_count_reaches_the_runner(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured_workers: list[int] = []
|
|
|
|
async def fake_run_source_jobs(_jobs, *, workers: int, **_kwargs):
|
|
captured_workers.append(workers)
|
|
return ()
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(theharvester_main, 'run_source_jobs', fake_run_source_jobs)
|
|
monkeypatch.setattr(
|
|
sys,
|
|
'argv',
|
|
['theHarvester', '-d', 'example.test', '-b', 'crtsh', '-j', '7', '--quiet'],
|
|
)
|
|
|
|
with pytest.raises(SystemExit) as exit_info:
|
|
await theharvester_main.start()
|
|
|
|
assert exit_info.value.code == 0
|
|
assert captured_workers == [7]
|
|
|
|
|
|
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_screenshot_scan_uses_one_bounded_reachability_and_capture_lifecycle(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
lifecycle_calls: list[str] = []
|
|
|
|
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:
|
|
raise AssertionError('the scan must not launch a separate installation-check browser')
|
|
|
|
async def reachable_targets(self, targets: list[str]) -> list[tuple[str, str]]:
|
|
lifecycle_calls.append('reachability')
|
|
assert targets == ['api.example.test']
|
|
return [('api.example.test', 'https://login.example.net/session')]
|
|
|
|
async def capture_targets(self, targets, record) -> None:
|
|
lifecycle_calls.append('capture')
|
|
assert targets == [('api.example.test', 'https://login.example.net/session')]
|
|
output_path = self.screenshot_path('api.example.test')
|
|
output_path.write_bytes(b'png')
|
|
await record('api.example.test', 'https://login.example.net/session', output_path)
|
|
|
|
def screenshot_path(self, subject: str) -> Path:
|
|
return Path(self.output) / f'{subject}.png'
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
|
|
result = await theharvester_main.start(
|
|
EnumerationOptions(domain='api.example.test', screenshot=str(tmp_path), quiet=True),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
assert lifecycle_calls == ['reachability', 'capture']
|
|
execution = next(item for item in result[-1].active_evidence.executions if item.action == 'screenshot')
|
|
assert execution.status == 'completed'
|
|
assert execution.artifacts[0].subject_value == 'api.example.test'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screenshot_cancellation_finishes_owned_artifact_record_before_checkpoint(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
saved: list[CompletedResult] = []
|
|
record_started = asyncio.Event()
|
|
release_record = asyncio.Event()
|
|
original_read_bytes = theharvester_main.anyio.Path.read_bytes
|
|
|
|
class RecordingResultStore(_NoopResultStore):
|
|
async def save_run(self, result: CompletedResult) -> None:
|
|
saved.append(result)
|
|
|
|
class Page:
|
|
async def goto(self, *_args, **_kwargs) -> None:
|
|
return None
|
|
|
|
async def screenshot(self, *, path: Path) -> None:
|
|
path.write_bytes(b'png') # noqa: ASYNC240 - tiny in-memory browser fixture
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
class Context:
|
|
async def new_page(self) -> Page:
|
|
return Page()
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
class Browser:
|
|
async def new_context(self) -> Context:
|
|
return Context()
|
|
|
|
async def close(self) -> None:
|
|
return None
|
|
|
|
class Playwright:
|
|
chromium = None
|
|
|
|
def __init__(self) -> None:
|
|
self.chromium = self
|
|
|
|
async def launch(self, **_kwargs) -> Browser:
|
|
return Browser()
|
|
|
|
class Manager:
|
|
async def __aenter__(self) -> Playwright:
|
|
return Playwright()
|
|
|
|
async def __aexit__(self, *_args) -> None:
|
|
return None
|
|
|
|
async def reachable_targets(_self, targets: list[str]) -> list[tuple[str, str]]:
|
|
assert targets == ['api.example.test']
|
|
return [('api.example.test', 'https://api.example.test')]
|
|
|
|
async def delayed_read_bytes(path) -> bytes:
|
|
if path.name == 'api.example.test.png':
|
|
record_started.set()
|
|
await release_record.wait()
|
|
return await original_read_bytes(path)
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', RecordingResultStore)
|
|
monkeypatch.setattr(screenshot_module, 'async_playwright', lambda: Manager())
|
|
monkeypatch.setattr(screenshot_module.ScreenShotter, 'reachable_targets', reachable_targets)
|
|
monkeypatch.setattr(theharvester_main.anyio.Path, 'read_bytes', delayed_read_bytes)
|
|
|
|
operation = asyncio.create_task(
|
|
theharvester_main.start(
|
|
EnumerationOptions(domain='api.example.test', screenshot=str(tmp_path), quiet=True),
|
|
return_completed_result=True,
|
|
)
|
|
)
|
|
await asyncio.wait_for(record_started.wait(), timeout=1)
|
|
operation.cancel('operator-stop')
|
|
await asyncio.sleep(0)
|
|
release_record.set()
|
|
with pytest.raises(asyncio.CancelledError, match='operator-stop'):
|
|
await operation
|
|
|
|
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 execution.artifacts[0].subject_value == 'api.example.test'
|
|
assert not [task for task in asyncio.all_tasks() if task.get_name().startswith('screenshot-')]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_screenshot_cleanup_failure_marks_the_action_failed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
page = MagicMock()
|
|
|
|
async def write_screenshot(*, path: Path) -> None:
|
|
path.write_bytes(b'png') # noqa: ASYNC240 - tiny in-memory browser fixture
|
|
|
|
page.goto = AsyncMock()
|
|
page.screenshot = AsyncMock(side_effect=write_screenshot)
|
|
page.close = AsyncMock(side_effect=RuntimeError('page close failed'))
|
|
context = MagicMock()
|
|
context.new_page = AsyncMock(return_value=page)
|
|
context.close = AsyncMock()
|
|
browser = MagicMock()
|
|
browser.new_context = AsyncMock(return_value=context)
|
|
browser.close = AsyncMock()
|
|
playwright = MagicMock()
|
|
playwright.chromium.launch = AsyncMock(return_value=browser)
|
|
manager = MagicMock()
|
|
manager.__aenter__ = AsyncMock(return_value=playwright)
|
|
manager.__aexit__ = AsyncMock(return_value=False)
|
|
|
|
async def reachable_targets(_self, targets: list[str]) -> list[tuple[str, str]]:
|
|
return [(targets[0], f'https://{targets[0]}')]
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(screenshot_module, 'async_playwright', MagicMock(return_value=manager))
|
|
monkeypatch.setattr(screenshot_module.ScreenShotter, 'reachable_targets', reachable_targets)
|
|
|
|
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 == 'capture-errors'
|
|
assert execution.error_type == 'RuntimeError'
|
|
page.close.assert_awaited_once()
|
|
context.close.assert_awaited_once()
|
|
browser.close.assert_awaited_once()
|
|
manager.__aexit__.assert_awaited_once()
|
|
|
|
|
|
@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(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
async def visit(self, host: str) -> tuple[str, str]:
|
|
return host, 'https'
|
|
|
|
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'
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
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(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
async def visit(self, _host: str) -> tuple[str, str]:
|
|
return '', ''
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
|
|
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(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
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'
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
|
|
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(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
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'
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
|
|
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(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
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'}
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', TwoHostSource)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
|
|
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(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
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'}
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', RecordingResultStore)
|
|
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', TwoHostSource)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
|
|
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(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
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'}
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', TwoHostSource)
|
|
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
|
|
|
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], *, target: str, nameservers: list[str]) -> None:
|
|
assert hosts == ['api.example.com']
|
|
assert target == 'example.com'
|
|
assert nameservers == ['192.0.2.53']
|
|
self.request_count = 2
|
|
self.request_error_count = 0
|
|
self.dns_error_count = 0
|
|
self.completed_count = 1
|
|
self.request_error_types: set[str] = set()
|
|
self.scan_error_type = None
|
|
self.stop_reason = None
|
|
|
|
async def process(self, proxy: bool = False) -> None:
|
|
assert proxy is True
|
|
|
|
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
|
return (_takeover_outcome(),)
|
|
|
|
class FakeScreenShotter(_FakeScreenshotBatch):
|
|
slash = '/'
|
|
|
|
def __init__(self, output: str) -> None:
|
|
self.output = output
|
|
|
|
def verify_path(self) -> bool:
|
|
return True
|
|
|
|
async def visit(self, host: str) -> tuple[str, str]:
|
|
return host, 'reachable'
|
|
|
|
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}
|
|
|
|
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(source_runner.crtsh, 'SearchCrtsh', FakeCrtsh)
|
|
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
|
|
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', 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.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',
|
|
'api.example.com',
|
|
)
|
|
assert takeover_result in completed.results
|
|
assert completed.takeover_outcomes == (_takeover_outcome(),)
|
|
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',
|
|
'inconclusive_count',
|
|
'expected_status',
|
|
'expected_error',
|
|
'expected_reason',
|
|
),
|
|
[
|
|
(2, 1, None, 0, 'partial', 'TransportError', 'request-errors'),
|
|
(2, 2, None, 1, 'failed', 'TransportError', 'incomplete-candidates'),
|
|
(0, 0, 'RuntimeError', 0, 'failed', 'RuntimeError', 'scan-error'),
|
|
(0, 0, None, 1, 'failed', 'WildcardIndistinguishableError', 'wildcard-indistinguishable'),
|
|
],
|
|
)
|
|
async def test_takeover_action_records_suppressed_outcome(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
request_count: int,
|
|
request_errors: int,
|
|
scan_error: str | None,
|
|
inconclusive_count: int,
|
|
expected_status: str,
|
|
expected_error: str | None,
|
|
expected_reason: str,
|
|
) -> None:
|
|
class FakeTakeOver:
|
|
def __init__(self, _hosts: list[str], **_kwargs: object) -> None:
|
|
self.request_count = request_count
|
|
self.request_error_count = request_errors
|
|
self.dns_error_count = 0
|
|
self.inconclusive_count = inconclusive_count
|
|
self.candidate_count = 1
|
|
self.completed_count = 0 if scan_error else 1
|
|
self.request_error_types = (
|
|
{'TransportError'} if request_errors else {'WildcardIndistinguishableError'} if inconclusive_count else set()
|
|
)
|
|
self.scan_error_type = scan_error
|
|
if scan_error:
|
|
self.stop_reason = 'scan-error'
|
|
elif inconclusive_count:
|
|
self.stop_reason = 'incomplete-candidates' if request_errors else 'wildcard-indistinguishable'
|
|
else:
|
|
self.stop_reason = None
|
|
|
|
async def process(self, proxy: bool = False) -> None:
|
|
assert proxy is False
|
|
return None
|
|
|
|
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
|
if scan_error:
|
|
return ()
|
|
if inconclusive_count:
|
|
return (
|
|
TakeoverCandidateOutcome.from_record(
|
|
'api.example.com',
|
|
{
|
|
'status': 'inconclusive',
|
|
'dns': [
|
|
{
|
|
'resolver': '1.1.1.1',
|
|
'cname_chain': ['missing-bucket.s3.amazonaws.com'],
|
|
'terminal_rcode': 'NOERROR',
|
|
}
|
|
],
|
|
'wildcard_dns': [
|
|
{
|
|
'resolver': '1.1.1.1',
|
|
'cname_chain': [],
|
|
'terminal_rcode': 'NXDOMAIN',
|
|
}
|
|
],
|
|
'http': [],
|
|
'indicators': [],
|
|
'error_types': ['TransportError'] if request_errors else ['WildcardIndistinguishableError'],
|
|
},
|
|
),
|
|
)
|
|
return (_takeover_outcome(),)
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', _ApiHostSource)
|
|
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', 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 if scan_error else 1)
|
|
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(source_runner.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(source_runner.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',
|
|
'scanner_reason',
|
|
'result_url',
|
|
'expected_status',
|
|
'expected_error',
|
|
'expected_reason',
|
|
),
|
|
[
|
|
('RuntimeError', 0, False, None, None, 'failed', 'RuntimeError', 'scan-error'),
|
|
(None, 3, False, None, None, 'partial', 'TransportError', 'request-errors'),
|
|
(None, 0, True, None, None, 'rate-limited', None, 'rate-limited'),
|
|
(None, 0, False, 'request-limit', None, 'failed', None, 'request-limit'),
|
|
(None, 1, False, None, 'https://example.com/api', 'partial', 'ResponseLimitError', 'request-errors'),
|
|
],
|
|
)
|
|
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,
|
|
scanner_reason: str | None,
|
|
result_url: str | None,
|
|
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 = {'ResponseLimitError' if result_url else 'TransportError'} if request_errors else set()
|
|
stop_reason = scanner_reason
|
|
|
|
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 {result_url: object()} if result_url else {}
|
|
|
|
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 == int(result_url is not None)
|
|
assert api_execution.error_type == expected_error
|
|
assert api_execution.stop_reason == expected_reason
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_scan_logs_result_collections_deterministically(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
def unordered_values(prefix: str) -> set[str]:
|
|
for index in range(100):
|
|
values = {f'{prefix}-a-{index}', f'{prefix}-z-{index}'}
|
|
if list(values) != sorted(values):
|
|
return values
|
|
raise AssertionError('could not construct an unordered set fixture')
|
|
|
|
endpoint_values = unordered_values('https://example.com/api')
|
|
method_values = unordered_values('METHOD')
|
|
|
|
class FakeApiScanner:
|
|
scan_error_type = None
|
|
request_error_count = 0
|
|
stop_reason = None
|
|
|
|
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
|
|
self.request_error_types: set[str] = set()
|
|
|
|
async def do_search(self) -> None:
|
|
return None
|
|
|
|
def get_found_endpoints(self) -> set[str]:
|
|
return endpoint_values
|
|
|
|
def get_interesting_endpoints(self) -> set[str]:
|
|
return set()
|
|
|
|
def get_auth_required(self) -> set[str]:
|
|
return set()
|
|
|
|
def get_api_versions(self) -> set[str]:
|
|
return set()
|
|
|
|
def get_rate_limits(self) -> dict[str, object]:
|
|
return {}
|
|
|
|
def get_methods(self) -> set[str]:
|
|
return method_values
|
|
|
|
def get_status_codes(self) -> set[int]:
|
|
return {200}
|
|
|
|
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', FakeApiScanner)
|
|
caplog.set_level(logging.INFO, logger='theHarvester.output')
|
|
|
|
await theharvester_main.start(
|
|
EnumerationOptions(api_scan=True, domain='example.com', quiet=True, wordlist=str(wordlist)),
|
|
return_completed_result=True,
|
|
)
|
|
|
|
rendered_endpoints = [
|
|
message.removeprefix(' - ') for message in caplog.messages if message.startswith(' - https://example.com/api')
|
|
]
|
|
assert rendered_endpoints == sorted(endpoint_values)
|
|
assert f'[*] HTTP methods used: {", ".join(sorted(method_values))}' in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
('limited_status', 'expected_status', 'expected_error', 'expected_reason'),
|
|
[
|
|
(429, 'rate-limited', None, 'rate-limited'),
|
|
(503, 'partial', 'HTTP503Error', 'request-errors'),
|
|
],
|
|
)
|
|
async def test_api_scan_reports_exhausted_retries_truthfully(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
tmp_path: Path,
|
|
limited_status: int,
|
|
expected_status: str,
|
|
expected_error: str | None,
|
|
expected_reason: str,
|
|
) -> None:
|
|
wordlist = tmp_path / 'api.txt'
|
|
wordlist.write_text('/api\n', encoding='utf-8')
|
|
|
|
async def detect_schema(self, _path: str = '') -> str:
|
|
return 'https'
|
|
|
|
async def fetch(*_args, **_kwargs) -> FetcherResponse:
|
|
return FetcherResponse(body='unavailable', status=limited_status, headers={'retry-after': '0'})
|
|
|
|
async def no_sleep(_delay: float) -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(theharvester_main.api_endpoints.SearchApiEndpoints, '_detect_schema', detect_schema)
|
|
monkeypatch.setattr(theharvester_main.api_endpoints.AsyncFetcher, 'fetch', fetch)
|
|
monkeypatch.setattr(theharvester_main.api_endpoints.asyncio, 'sleep', no_sleep)
|
|
caplog.set_level(logging.INFO, logger='theHarvester.output')
|
|
|
|
result = 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 result[-1].active_evidence.executions if item.action == 'api-scan')
|
|
assert execution.status == expected_status
|
|
assert execution.result_count == 1
|
|
assert execution.error_type == expected_error
|
|
assert execution.stop_reason == expected_reason
|
|
assert 'API scanning completed successfully.' not in caplog.text
|
|
assert f'API scanning stopped with reason: {expected_reason}.' in caplog.text
|
|
|
|
|
|
@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], **_kwargs: object) -> None:
|
|
raise AssertionError('takeover should not start without hosts')
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
|
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', 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, limit: int) -> None:
|
|
assert limit == 500
|
|
|
|
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(source_runner.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], **_kwargs: object) -> None:
|
|
self.request_count = 0
|
|
self.request_error_count = 0
|
|
self.dns_error_count = 0
|
|
self.completed_count = 0
|
|
self.request_error_types: set[str] = set()
|
|
self.scan_error_type = None
|
|
self.stop_reason = None
|
|
if failure_stage == 'init':
|
|
raise raised_error
|
|
|
|
async def process(self, _proxy: bool = False, **_kwargs) -> None:
|
|
if failure_stage == 'process':
|
|
raise raised_error
|
|
|
|
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
|
if failure_stage == 'getter':
|
|
raise raised_error
|
|
return ()
|
|
|
|
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
|
|
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', _ApiHostSource)
|
|
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', 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(source_runner.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], **_kwargs: object) -> None:
|
|
self.request_count = 1
|
|
self.request_error_count = 0
|
|
self.dns_error_count = 0
|
|
self.completed_count = 1
|
|
self.request_error_types: set[str] = set()
|
|
self.scan_error_type = None
|
|
self.stop_reason = None
|
|
|
|
async def process(self, proxy: bool = False) -> None:
|
|
assert proxy is False
|
|
|
|
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
|
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(source_runner.crtsh, 'SearchCrtsh', _ApiHostSource)
|
|
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', 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(source_runner.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, None)]
|
|
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(source_runner.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)
|