import asyncio import json import ssl import subprocess from pathlib import Path from types import SimpleNamespace import aiohttp import pytest from theHarvester.discovery import api_endpoints from theHarvester.lib.core import FetcherResponse, ResponseStreamError class FakeResponse: async def __aenter__(self) -> 'FakeResponse': return self async def __aexit__(self, *_args) -> None: return None class FakeSession: def __init__(self, error: Exception | None = None) -> None: self.error = error self.requests: list[tuple[str, dict[str, object]]] = [] async def __aenter__(self) -> 'FakeSession': return self async def __aexit__(self, *_args) -> None: return None def get(self, url: str, **kwargs) -> FakeResponse: self.requests.append((url, kwargs)) if self.error: raise self.error return FakeResponse() @pytest.fixture(scope='module') def api_tls_cert_chain(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path]: tls_directory = tmp_path_factory.mktemp('api-endpoint-tls') certificate = tls_directory / 'certificate.pem' private_key = tls_directory / 'private-key.pem' subprocess.run( [ 'openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-sha256', '-days', '1', '-subj', '/CN=127.0.0.1', '-keyout', str(private_key), '-out', str(certificate), ], check=True, capture_output=True, ) return certificate, private_key def test_process_response_extracts_only_string_json_parameter_names(monkeypatch): search = api_endpoints.SearchApiEndpoints('example.com') response = SimpleNamespace( status=200, headers={'Content-Type': 'application/json'}, content=b'{"name": "value"}', ) monkeypatch.setattr(api_endpoints.json, 'loads', lambda _content: {'name': 'value', 1: 'ignored'}) result = search._process_response('https://example.com/api/v1/users', 'GET', response, 0.1) assert result is not None assert result.parameters == ['name'] def test_process_response_parses_json_once_independent_of_technology_count(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com') search.tech_patterns = {f'tech-{index}': ['never-match'] for index in range(25)} response = SimpleNamespace( status=200, headers={'Content-Type': 'application/json'}, content=b'{"first": 1, "second": 2}', ) parse_count = 0 def loads(_content: bytes) -> dict[str, str | int]: nonlocal parse_count parse_count += 1 return {'openapi': '3.0', 'first': 1, 'second': 2} monkeypatch.setattr(api_endpoints.json, 'loads', loads) result = search._process_response('https://example.com/swagger', 'GET', response, 0.1) assert result is not None assert result.parameters == ['openapi', 'first', 'second'] assert search.get_schema_detected() == {'https://example.com/swagger': {'openapi': '3.0', 'first': 1, 'second': 2}} assert parse_count == 1 def test_schema_getter_preserves_full_documents() -> None: search = api_endpoints.SearchApiEndpoints('example.com') document = { 'openapi': '3.1.0', 'paths': {f'/resource/{index}': {'description': 'x' * 2048} for index in range(64)}, } content = json.dumps(document).encode() search._process_response( 'https://example.com/openapi.json', 'GET', SimpleNamespace(status=200, headers={'content-type': 'application/json'}, body=content), 0.1, ) assert search.get_schema_detected() == {'https://example.com/openapi.json': document} def test_api_endpoint_scan_defaults_to_direct_requests_with_redirects() -> None: search = api_endpoints.SearchApiEndpoints('example.com') assert search.proxy is None assert search.follow_redirects is True assert search.concurrency == 10 assert search.request_limit is None assert search.runtime_seconds is None assert api_endpoints.SearchApiEndpoints('example.com', concurrency=20).concurrency == 20 def test_api_endpoint_scan_accepts_explicitly_unlimited_library_overrides() -> None: search = api_endpoints.SearchApiEndpoints('example.com', request_limit=None, runtime_seconds=None) assert search.request_limit is None assert search.runtime_seconds is None @pytest.mark.parametrize('concurrency', [0, -1, True, 1.5]) def test_api_endpoint_scan_rejects_invalid_concurrency(concurrency: object) -> None: with pytest.raises(ValueError, match='concurrency must be a positive integer'): api_endpoints.SearchApiEndpoints('example.com', concurrency=concurrency) @pytest.mark.parametrize( ('option', 'value'), [ ('timeout', 0), ('request_limit', 0), ('runtime_seconds', 0), ('response_body_limit', 0), ], ) def test_api_endpoint_scan_rejects_nonpositive_budgets(option: str, value: int) -> None: with pytest.raises(ValueError, match=option): api_endpoints.SearchApiEndpoints('example.com', **{option: value}) @pytest.mark.parametrize('option', ['timeout', 'runtime_seconds']) @pytest.mark.parametrize('value', [float('nan'), float('inf'), float('-inf')]) def test_api_endpoint_scan_rejects_nonfinite_time_budgets(option: str, value: float) -> None: with pytest.raises(ValueError, match=option): api_endpoints.SearchApiEndpoints('example.com', **{option: value}) def test_api_endpoint_scan_rejects_fractional_request_timeout() -> None: with pytest.raises(ValueError, match='timeout must be a positive integer'): api_endpoints.SearchApiEndpoints('example.com', timeout=1.5) @pytest.mark.asyncio async def test_api_endpoint_scan_bounds_active_and_pending_work_and_preserves_order(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=3, exact_paths=True) paths = [f'/api/{index}' for index in range(1_001)] active = 0 peak_active = 0 peak_pending = 0 first_release = asyncio.Event() baseline_tasks = len(asyncio.all_tasks()) monkeypatch.setattr(search, '_load_wordlist', lambda: paths) async def detect_schema(_path: str = '') -> str: return 'https' async def fetch(session=None, url: str = '', **_kwargs) -> FetcherResponse: nonlocal active, peak_active, peak_pending assert session is not None active += 1 peak_active = max(peak_active, active) peak_pending = max(peak_pending, len(asyncio.all_tasks()) - baseline_tasks) try: if url.endswith('/0'): await first_release.wait() else: first_release.set() await asyncio.sleep(0) return FetcherResponse(body='{}', status=200, headers={'content-type': 'application/json'}) finally: active -= 1 monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() expected_urls = [f'https://example.com{path}' for path in paths] assert [result['url'] for result in search.get_detailed_results()] == expected_urls assert search.request_count == len(paths) + 1 # schema detection plus every endpoint assert peak_active == 3 assert peak_pending <= 3 assert not any(task.get_name().startswith('api-endpoint-worker-') for task in asyncio.all_tasks()) @pytest.mark.asyncio async def test_concurrent_api_endpoint_scans_have_independent_worker_limits(monkeypatch) -> None: scans = [api_endpoints.SearchApiEndpoints(f'{index}.example.com', concurrency=1, exact_paths=True) for index in range(2)] active = 0 peak_active = 0 both_started = asyncio.Event() for search in scans: monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) async def detect_schema(_path: str = '') -> str: return 'https' monkeypatch.setattr(search, '_detect_schema', detect_schema) async def fetch(*_args, **_kwargs) -> FetcherResponse: nonlocal active, peak_active active += 1 peak_active = max(peak_active, active) if active == 2: both_started.set() try: await both_started.wait() return FetcherResponse(body='{}', status=200, headers={}) finally: active -= 1 monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await asyncio.gather(*(search.do_search() for search in scans)) assert peak_active == 2 assert [len(search.get_found_endpoints()) for search in scans] == [1, 1] @pytest.mark.asyncio async def test_api_endpoint_scan_reuses_keepalive_transport_with_bounded_connector(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=10, exact_paths=True) connector_options: dict[str, object] = {} sessions: list[object] = [] ssl_policies: list[object] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/one', '/two']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) def connector(**kwargs): connector_options.update(kwargs) return object() class Session: def __init__(self, **kwargs) -> None: self.headers = kwargs['headers'] self.connector = kwargs.get('connector') self.closed = False sessions.append(self) async def close(self) -> None: self.closed = True async def fetch(session=None, url: str = '', **kwargs) -> FetcherResponse: assert session is sessions[0] ssl_policies.append(kwargs['verify']) return FetcherResponse(body=url, status=200, headers={}) monkeypatch.setattr(api_endpoints.aiohttp, 'TCPConnector', connector) monkeypatch.setattr(api_endpoints.aiohttp, 'ClientSession', Session) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert connector_options == {'limit': 2, 'limit_per_host': 2, 'ssl': True} assert sessions[0].connector is not None assert 'Connection' not in sessions[0].headers assert ssl_policies == [True, True] assert ssl_policies[0] is ssl_policies[1] assert sessions[0].closed is True @pytest.mark.asyncio async def test_api_endpoint_scan_closes_connector_when_session_construction_fails(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', exact_paths=True) class Connector: def __init__(self) -> None: self.closed = False async def close(self) -> None: self.closed = True connector = Connector() monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) monkeypatch.setattr(api_endpoints.aiohttp, 'TCPConnector', lambda **_kwargs: connector) def fail_session(**_kwargs): raise RuntimeError('session construction failed') monkeypatch.setattr(api_endpoints.aiohttp, 'ClientSession', fail_session) await search.do_search() assert connector.closed is True assert search.scan_error_type == 'RuntimeError' assert search.stop_reason == 'scan-error' @pytest.mark.asyncio async def test_api_endpoint_scan_reuses_one_real_https_connection( monkeypatch: pytest.MonkeyPatch, api_tls_cert_chain: tuple[Path, Path], ) -> None: certificate, private_key = api_tls_cert_chain server_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) server_context.load_cert_chain(certificate, private_key) accepted_connections = 0 requested_paths: list[str] = [] async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: nonlocal accepted_connections accepted_connections += 1 try: while True: try: request = await reader.readuntil(b'\r\n\r\n') except asyncio.IncompleteReadError: break requested_paths.append(request.split(b' ', 2)[1].decode()) writer.write(b'HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\n{}') await writer.drain() finally: writer.close() await writer.wait_closed() server = await asyncio.start_server(handle, '127.0.0.1', 0, ssl=server_context) port = server.sockets[0].getsockname()[1] search = api_endpoints.SearchApiEndpoints( f'127.0.0.1:{port}', concurrency=1, exact_paths=True, verify_ssl=False, ) monkeypatch.setattr(search, '_load_wordlist', lambda: ['/one', '/two']) try: await search.do_search() finally: server.close() await server.wait_closed() assert list(search.get_found_endpoints()) == [ f'https://127.0.0.1:{port}/one', f'https://127.0.0.1:{port}/two', ] assert requested_paths == ['/one', '/one', '/two'] assert search._ssl_policy is False assert accepted_connections == 1 @pytest.mark.asyncio async def test_api_endpoint_scan_stops_at_the_total_request_budget(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints( 'example.com', concurrency=3, exact_paths=True, request_limit=3, ) requested_urls: list[str] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/one', '/two', '/three', '/four']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(session=None, url: str = '', **_kwargs) -> FetcherResponse: requested_urls.append(url) await asyncio.sleep(0) return FetcherResponse(body='{}', status=200, headers={}) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert search.request_count == 3 assert search.stop_reason == 'request-limit' assert requested_urls == ['https://example.com/one', 'https://example.com/two'] assert list(search.get_found_endpoints()) == requested_urls @pytest.mark.asyncio async def test_api_endpoint_scan_retains_completed_results_at_the_runtime_budget(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints( 'example.com', concurrency=1, exact_paths=True, runtime_seconds=0.01, ) never = asyncio.Event() monkeypatch.setattr(search, '_load_wordlist', lambda: ['/done', '/slow']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(session=None, url: str = '', **_kwargs) -> FetcherResponse: if url.endswith('/slow'): await never.wait() return FetcherResponse(body='{}', status=200, headers={}) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert search.stop_reason == 'runtime-limit' assert list(search.get_found_endpoints()) == ['https://example.com/done'] assert not any(task.get_name().startswith('api-endpoint-worker-') for task in asyncio.all_tasks()) @pytest.mark.asyncio async def test_api_endpoint_scan_runtime_budget_includes_post_scan_analysis(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', exact_paths=True, runtime_seconds=0.01) never = asyncio.Event() monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) monkeypatch.setattr(search, '_post_scan_analysis', never.wait) async def fetch(*_args, **_kwargs) -> FetcherResponse: return FetcherResponse(body='{}', status=200, headers={}) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert search.stop_reason == 'runtime-limit' assert list(search.get_found_endpoints()) == ['https://example.com/api'] @pytest.mark.asyncio async def test_api_endpoint_scan_cancellation_awaits_workers_and_closes_transport(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=3, exact_paths=True) sessions: list[object] = [] active = 0 all_started = asyncio.Event() never = asyncio.Event() monkeypatch.setattr(search, '_load_wordlist', lambda: ['/one', '/two', '/three']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) monkeypatch.setattr(api_endpoints.aiohttp, 'TCPConnector', lambda **_kwargs: object()) class Session: def __init__(self, **_kwargs) -> None: self.closed = False sessions.append(self) async def close(self) -> None: self.closed = True async def fetch(session=None, url: str = '', **_kwargs) -> FetcherResponse: nonlocal active active += 1 if active == 3: all_started.set() try: await never.wait() return FetcherResponse(body=url, status=200, headers={}) finally: active -= 1 monkeypatch.setattr(api_endpoints.aiohttp, 'ClientSession', Session) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) scan = asyncio.create_task(search.do_search()) await all_started.wait() scan.cancel() with pytest.raises(asyncio.CancelledError): await scan assert search.stop_reason == 'cancelled' assert search.scan_error_type == 'CancelledError' assert active == 0 assert sessions[0].closed is True assert not any(task.get_name().startswith('api-endpoint-worker-') for task in asyncio.all_tasks()) @pytest.mark.asyncio async def test_api_endpoint_scan_preserves_first_cancellation_during_worker_drain(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=2, exact_paths=True) first_cancel = asyncio.CancelledError('operator-stop-first') both_started = asyncio.Event() cleanup_started = asyncio.Event() release_cleanup = asyncio.Event() started = 0 sessions: list[object] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/cancel', '/slow']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) monkeypatch.setattr(api_endpoints.aiohttp, 'TCPConnector', lambda **_kwargs: object()) class Session: def __init__(self, **_kwargs) -> None: self.closed = False sessions.append(self) async def close(self) -> None: self.closed = True async def fetch(session=None, url: str = '', **_kwargs) -> FetcherResponse: nonlocal started started += 1 if started == 2: both_started.set() await both_started.wait() if url.endswith('/cancel'): raise first_cancel try: await asyncio.Event().wait() except asyncio.CancelledError: cleanup_started.set() await release_cleanup.wait() raise monkeypatch.setattr(api_endpoints.aiohttp, 'ClientSession', Session) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) scan = asyncio.create_task(search.do_search()) await cleanup_started.wait() scan.cancel('operator-stop-second') release_cleanup.set() with pytest.raises(asyncio.CancelledError) as raised: await scan assert raised.value is first_cancel assert search.stop_reason == 'cancelled' assert sessions[0].closed is True assert not any(task.get_name().startswith('api-endpoint-worker-') for task in asyncio.all_tasks()) @pytest.mark.asyncio async def test_api_endpoint_scan_preserves_first_cancellation_while_closing_session(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=1, exact_paths=True) first_cancel = asyncio.CancelledError('operator-stop-first') close_started = asyncio.Event() release_close = asyncio.Event() sessions: list[object] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/cancel']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) monkeypatch.setattr(api_endpoints.aiohttp, 'TCPConnector', lambda **_kwargs: object()) class Session: def __init__(self, **_kwargs) -> None: self.closed = False sessions.append(self) async def close(self) -> None: close_started.set() await release_close.wait() self.closed = True async def fetch(*_args, **_kwargs) -> FetcherResponse: raise first_cancel monkeypatch.setattr(api_endpoints.aiohttp, 'ClientSession', Session) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) scan = asyncio.create_task(search.do_search()) await close_started.wait() scan.cancel('operator-stop-second') release_close.set() with pytest.raises(asyncio.CancelledError) as raised: await scan assert raised.value is first_cancel assert search.stop_reason == 'cancelled' assert sessions[0].closed is True assert not any(task.get_name() == 'api-endpoint-session-close' for task in asyncio.all_tasks()) @pytest.mark.asyncio async def test_api_endpoint_scan_keeps_oversized_response_evidence_and_scans_siblings(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints( 'example.com', concurrency=1, exact_paths=True, response_body_limit=4, ) requested_urls: list[str] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/large', '/sibling']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(*_args, url: str = '', **kwargs) -> FetcherResponse: requested_urls.append(url) assert kwargs['response_byte_limit'] == 4 assert 'response_byte_account' not in kwargs if url.endswith('/large'): raise ResponseStreamError( 'response-limit', status=206, headers={ 'allow': 'GET, HEAD, OPTIONS', 'content-type': 'application/json', 'set-cookie': 'secret', 'x-api-key': 'reflected-secret', 'x-auth-token': 'reflected-secret', 'x-csrf-token': 'reflected-secret', }, ) return FetcherResponse(body='{}', status=200, headers={}) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert search.stop_reason == 'request-errors' assert search.scan_error_type is None assert search.request_error_count == 1 assert search.request_error_types == {'ResponseLimitError'} assert search.request_count == 3 assert requested_urls == ['https://example.com/large', 'https://example.com/sibling'] large = search.get_found_endpoints()['https://example.com/large'] assert large.status_code == 206 assert large.response_headers == { 'allow': 'GET, HEAD, OPTIONS', 'content-type': 'application/json', } assert large.body_truncated is True assert list(search.get_found_endpoints()) == requested_urls @pytest.mark.asyncio async def test_api_endpoint_scan_accepts_bodies_over_the_former_cumulative_budget(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints( 'example.com', concurrency=1, exact_paths=True, response_body_limit=512 * 1024, ) monkeypatch.setattr(search, '_load_wordlist', lambda: ['/one', '/two', '/three']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(*_args, **kwargs) -> FetcherResponse: assert kwargs['response_byte_limit'] == 512 * 1024 assert 'response_byte_account' not in kwargs return FetcherResponse(body='x' * (512 * 1024), status=200, headers={}) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert list(search.get_found_endpoints()) == [ 'https://example.com/one', 'https://example.com/two', 'https://example.com/three', ] assert sum(search.response_sizes.values()) > 1024 * 1024 assert search.stop_reason is None assert search.request_error_count == 0 @pytest.mark.asyncio @pytest.mark.parametrize( ('limited_status', 'headers', 'expected_delay'), [ (429, {'Retry-After': '2'}, 2.25), (503, {'Retry-After': 'invalid'}, 0.75), (503, {'Retry-After': 'Fri, 31 Dec 2099 23:59:59 GMT'}, 30.0), ], ) async def test_api_endpoint_scan_retries_rate_limits_with_bounded_jitter( monkeypatch, limited_status: int, headers: dict[str, str], expected_delay: float, ) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=1, exact_paths=True) responses = iter( [ FetcherResponse(body='limited', status=limited_status, headers=headers), FetcherResponse(body='{}', status=200, headers={'content-type': 'application/json'}), ] ) sleeps: list[float] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) monkeypatch.setattr(api_endpoints.random, 'uniform', lambda _start, _end: 0.25) async def detect_schema(_path: str = '') -> str: return 'https' async def sleep(delay: float) -> None: sleeps.append(delay) async def fetch(*_args, **_kwargs) -> FetcherResponse: return next(responses) monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.asyncio, 'sleep', sleep) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() result = search.get_found_endpoints()['https://example.com/api'] assert result.status_code == 200 assert search.request_count == 3 assert sleeps == [expected_delay] assert search.get_rate_limits() == {} @pytest.mark.asyncio @pytest.mark.parametrize('limited_status', [429, 503]) async def test_api_endpoint_scan_retries_oversized_rate_limit_responses(monkeypatch, limited_status: int) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=1, exact_paths=True) calls = 0 sleeps: list[float] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) async def detect_schema(_path: str = '') -> str: return 'https' async def fetch(*_args, **_kwargs) -> FetcherResponse: nonlocal calls calls += 1 if calls == 1: raise ResponseStreamError('response-limit', status=limited_status, headers={'retry-after': '0'}) return FetcherResponse(body='{}', status=200, headers={'content-type': 'application/json'}) async def sleep(delay: float) -> None: sleeps.append(delay) monkeypatch.setattr(api_endpoints.random, 'uniform', lambda _start, _end: 0.25) monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.asyncio, 'sleep', sleep) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert calls == 2 assert sleeps == [0.25] assert search.get_found_endpoints()['https://example.com/api'].status_code == 200 assert search.request_error_count == 0 assert search.request_error_types == set() @pytest.mark.asyncio async def test_api_endpoint_scan_blocks_redirects_outside_the_authorized_target(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=1, exact_paths=True) requests: list[tuple[str, bool]] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(*_args, url: str = '', **kwargs) -> FetcherResponse: requests.append((url, kwargs['follow_redirects'])) assert url == 'https://example.com/api' return FetcherResponse(body='', status=302, headers={'location': 'https://outside.invalid/collect'}) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert requests == [('https://example.com/api', False)] assert search.request_error_count == 1 assert search.request_error_types == {'RedirectScopeError'} assert search.stop_reason == 'request-errors' assert search.get_found_endpoints()['https://example.com/api'].response_headers == { 'location': 'https://outside.invalid/collect' } @pytest.mark.asyncio async def test_api_endpoint_scan_follows_redirects_within_the_authorized_target(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=1, exact_paths=True) requests: list[tuple[str, bool]] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(*_args, url: str = '', **kwargs) -> FetcherResponse: requests.append((url, kwargs['follow_redirects'])) if url.endswith('/api'): return FetcherResponse(body='', status=302, headers={'location': '/api/v2'}) return FetcherResponse(body='{}', status=200, headers={'content-type': 'application/json'}) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert requests == [ ('https://example.com/api', False), ('https://example.com/api/v2', False), ] assert search.request_count == 3 assert search.stop_reason is None assert search.get_found_endpoints()['https://example.com/api'].status_code == 200 @pytest.mark.asyncio async def test_api_endpoint_scan_follows_oversized_redirects_within_the_authorized_target(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=1, exact_paths=True) requests: list[str] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(*_args, url: str = '', **_kwargs) -> FetcherResponse: requests.append(url) if url.endswith('/api'): raise ResponseStreamError('response-limit', status=302, headers={'location': '/api/v2'}) return FetcherResponse(body='{}', status=200, headers={'content-type': 'application/json'}) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert requests == ['https://example.com/api', 'https://example.com/api/v2'] assert search.request_error_count == 0 assert search.request_error_types == set() assert search.get_found_endpoints()['https://example.com/api'].status_code == 200 @pytest.mark.asyncio async def test_api_endpoint_scan_blocks_oversized_redirects_outside_the_authorized_target(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=1, exact_paths=True) requests: list[str] = [] monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(*_args, url: str = '', **_kwargs) -> FetcherResponse: requests.append(url) raise ResponseStreamError( 'response-limit', status=302, headers={'location': 'https://outside.invalid/collect'}, ) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert requests == ['https://example.com/api'] assert search.request_error_count == 1 assert search.request_error_types == {'RedirectScopeError'} result = search.get_found_endpoints()['https://example.com/api'] assert result.status_code == 302 assert result.body_truncated is True @pytest.mark.asyncio @pytest.mark.parametrize( ('follow_redirects', 'headers'), [ (False, {'location': '/api/v2'}), (True, {}), ], ) async def test_api_endpoint_scan_reports_terminal_oversized_redirects_as_truncated( monkeypatch, follow_redirects: bool, headers: dict[str, str], ) -> None: search = api_endpoints.SearchApiEndpoints( 'example.com', concurrency=1, exact_paths=True, follow_redirects=follow_redirects, ) monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) monkeypatch.setattr(search, '_detect_schema', lambda _path='': asyncio.sleep(0, result='https')) async def fetch(*_args, **_kwargs) -> FetcherResponse: raise ResponseStreamError('response-limit', status=302, headers=headers) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert search.request_error_count == 1 assert search.request_error_types == {'ResponseLimitError'} assert search.stop_reason == 'request-errors' result = search.get_found_endpoints()['https://example.com/api'] assert result.status_code == 302 assert result.body_truncated is True @pytest.mark.asyncio @pytest.mark.parametrize( ('limited_status', 'expected_stop_reason', 'expected_error_types'), [ (429, 'rate-limited', set()), (503, 'request-errors', {'HTTP503Error'}), ], ) async def test_api_endpoint_scan_caps_retries_with_a_truthful_outcome( monkeypatch, limited_status: int, expected_stop_reason: str, expected_error_types: set[str], ) -> None: search = api_endpoints.SearchApiEndpoints('example.com', concurrency=1, exact_paths=True) calls = 0 monkeypatch.setattr(search, '_load_wordlist', lambda: ['/api']) async def detect_schema(_path: str = '') -> str: return 'https' async def fetch(*_args, **_kwargs) -> FetcherResponse: nonlocal calls calls += 1 return FetcherResponse(body='unavailable', status=limited_status, headers={'retry-after': '0'}) async def no_sleep(_delay: float) -> None: return None monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.asyncio, 'sleep', no_sleep) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert calls == search.MAX_RETRIES + 1 assert search.request_count == calls + 1 assert search.get_found_endpoints()['https://example.com/api'].status_code == limited_status assert search.stop_reason == expected_stop_reason assert search.request_error_count == int(limited_status == 503) assert search.request_error_types == expected_error_types @pytest.mark.asyncio async def test_api_endpoint_scan_uses_only_observational_http_methods(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('192.0.2.1') search.common_api_paths = ['/api'] methods = [] monkeypatch.setattr(search, '_load_wordlist', lambda: []) async def detect_schema(): return 'https' async def fetch(*_args, method='GET', **_kwargs): methods.append(method) return '' monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert methods == ['GET', 'HEAD', 'OPTIONS'] @pytest.mark.asyncio async def test_api_endpoint_scan_uses_only_the_configured_wordlist(monkeypatch, tmp_path) -> None: wordlist = tmp_path / 'operator-paths.txt' wordlist.write_text('/health\n', encoding='utf-8') search = api_endpoints.SearchApiEndpoints('example.com', wordlist=str(wordlist), exact_paths=True) detected_paths: list[str] = [] requested_urls: list[str] = [] async def detect_schema(path: str = '') -> str: detected_paths.append(path) return 'https' async def fetch(url: str, *_args, **_kwargs): requested_urls.append(url) return '' monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert detected_paths == ['/health'] assert requested_urls == ['https://example.com/health'] * 3 @pytest.mark.asyncio async def test_schema_detection_can_probe_an_exact_listed_path() -> None: search = api_endpoints.SearchApiEndpoints('example.com', exact_paths=True) session = FakeSession() search._session = session assert await search._detect_schema('/health') == 'https' assert session.requests[0][0] == 'https://example.com/health' @pytest.mark.asyncio async def test_api_endpoint_scan_exposes_shared_fetcher_metadata(monkeypatch: pytest.MonkeyPatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com') search.common_api_paths = ['/api'] monkeypatch.setattr(search, '_load_wordlist', lambda: []) async def detect_schema() -> str: return 'https' async def fetch(*_args, **kwargs): assert kwargs['include_metadata'] is True assert kwargs['request_timeout'] == 10 assert kwargs['response_byte_limit'] == 1024 * 1024 return FetcherResponse( body='{"status":"ok"}', status=200, headers={'content-type': 'application/json'}, ) monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() result = search.get_found_endpoints()['https://example.com/api'] assert result.status_code == 200 assert result.method == 'GET' assert result.content_type == 'application/json' assert result.content_length == len('{"status":"ok"}') assert result.content_preview == '{"status":"ok"}' @pytest.mark.asyncio async def test_api_endpoint_scan_counts_suppressed_request_failures(monkeypatch: pytest.MonkeyPatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com') search.common_api_paths = ['/api'] monkeypatch.setattr(search, '_load_wordlist', lambda: []) async def detect_schema() -> str: return 'https' async def fail_request(*_args, **kwargs): assert kwargs['include_metadata'] is True return None monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fail_request) await search.do_search() assert search.request_error_count == 3 assert search.request_error_types == {'TransportError'} assert search.get_found_endpoints() == {} @pytest.mark.asyncio async def test_api_endpoint_scan_reports_suppressed_top_level_failure(monkeypatch: pytest.MonkeyPatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com') async def fail_schema_detection() -> str: raise RuntimeError('scan setup failed') monkeypatch.setattr(search, '_detect_schema', fail_schema_detection) assert await search.do_search() is None assert search.scan_error_type == 'RuntimeError' @pytest.mark.asyncio async def test_api_endpoint_scan_allows_an_operator_selected_private_target(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('100.64.0.1') search.common_api_paths = ['/api'] requests = [] monkeypatch.setattr(search, '_load_wordlist', lambda: []) async def detect_schema(): return 'https' async def fetch(*_args, **_kwargs): requests.append(True) return '' monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert requests == [True, True, True] @pytest.mark.asyncio async def test_api_endpoint_scan_uses_a_configured_proxy(monkeypatch) -> None: proxy = 'http://proxy.example:8080' search = api_endpoints.SearchApiEndpoints('192.0.2.1', proxy=proxy) search.common_api_paths = ['/api'] requests = [] monkeypatch.setattr(search, '_load_wordlist', lambda: []) async def detect_schema(): return 'https' async def fetch(*_args, **kwargs): requests.append((kwargs['proxy'], kwargs['follow_redirects'])) return '' monkeypatch.setattr(search, '_detect_schema', detect_schema) monkeypatch.setattr(api_endpoints.AsyncFetcher, 'fetch', fetch) await search.do_search() assert requests == [(proxy, False), (proxy, False), (proxy, False)] @pytest.mark.parametrize('error', [aiohttp.ClientConnectionError(), TimeoutError()]) @pytest.mark.asyncio async def test_detect_schema_falls_back_only_when_https_cannot_connect(monkeypatch, error: Exception) -> None: search = api_endpoints.SearchApiEndpoints('example.com') search._session = FakeSession(error) assert await search._detect_schema() == 'http' @pytest.mark.asyncio async def test_detect_schema_does_not_downgrade_after_https_client_error(monkeypatch) -> None: search = api_endpoints.SearchApiEndpoints('example.com') search._session = FakeSession(aiohttp.ClientPayloadError('bad payload')) with pytest.raises(aiohttp.ClientPayloadError, match='bad payload'): await search._detect_schema() @pytest.mark.asyncio async def test_detect_schema_reuses_session_with_configured_request_policy(monkeypatch) -> None: session = FakeSession() search = api_endpoints.SearchApiEndpoints('example.com', follow_redirects=True) search._session = session monkeypatch.setattr( api_endpoints.aiohttp, 'ClientSession', lambda **_kwargs: pytest.fail('schema detection must reuse the scan session'), ) assert await search._detect_schema() == 'https' assert session.requests == [ ( 'https://example.com', {'proxy': None, 'ssl': True, 'allow_redirects': False}, ) ]