mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-23 14:22:24 +02:00
* fix: complete API endpoint scans by default * refactor: reuse shared cancellation cleanup
1150 lines
41 KiB
Python
1150 lines
41 KiB
Python
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},
|
|
)
|
|
]
|