mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-17 19:35:40 +02:00
313 lines
11 KiB
Python
313 lines
11 KiB
Python
import asyncio
|
|
import contextlib
|
|
import sys
|
|
import types
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
if 'aiohttp_socks' not in sys.modules:
|
|
aiohttp_socks_stub = types.ModuleType('aiohttp_socks')
|
|
|
|
class _ProxyConnector:
|
|
@staticmethod
|
|
def from_url(*_args, **_kwargs):
|
|
return None
|
|
|
|
aiohttp_socks_stub.ProxyConnector = _ProxyConnector
|
|
sys.modules['aiohttp_socks'] = aiohttp_socks_stub
|
|
|
|
from theHarvester.discovery import censysearch
|
|
from theHarvester.discovery.constants import MissingKey
|
|
from theHarvester.lib.core import FetcherResponse
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_platform_token_raises(monkeypatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: (None, 'org-id'))
|
|
|
|
with pytest.raises(MissingKey, match='Personal Access Token'):
|
|
censysearch.SearchCensys('example.com')
|
|
|
|
|
|
def test_legacy_search_api_credentials_fail_closed(monkeypatch) -> None:
|
|
monkeypatch.setattr(
|
|
censysearch.Core,
|
|
'api_keys',
|
|
staticmethod(lambda: {'censys': {'id': 'legacy-id', 'secret': 'legacy-secret'}}),
|
|
)
|
|
|
|
with pytest.raises(MissingKey, match='Personal Access Token'):
|
|
censysearch.SearchCensys('example.com')
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_calls_platform_api_directly_and_follows_page_tokens(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', 'org-id'))
|
|
calls: list[dict[str, object]] = []
|
|
session_options: list[dict[str, object]] = []
|
|
session = object()
|
|
responses = [
|
|
FetcherResponse(
|
|
{
|
|
'result': {
|
|
'hits': [
|
|
{
|
|
'certificate_v1': {
|
|
'resource': {
|
|
'names': ['a.example.com'],
|
|
'parsed': {'subject': {'email_address': ['admin@example.com']}},
|
|
}
|
|
}
|
|
},
|
|
{'host_v1': {'resource': {'ip': '192.0.2.1'}}},
|
|
],
|
|
'next_page_token': 'next-page',
|
|
}
|
|
},
|
|
200,
|
|
{},
|
|
),
|
|
FetcherResponse(
|
|
{
|
|
'result': {
|
|
'hits': [
|
|
{
|
|
'certificate_v1': {
|
|
'resource': {
|
|
'names': ['b.example.com'],
|
|
'parsed': {'subject': {'email_address': 'ops@example.com'}},
|
|
}
|
|
}
|
|
}
|
|
],
|
|
'next_page_token': '',
|
|
}
|
|
},
|
|
200,
|
|
{},
|
|
),
|
|
]
|
|
|
|
async def fake_post_fetch(url: str, **kwargs: Any) -> FetcherResponse:
|
|
calls.append({'url': url, **kwargs})
|
|
return responses.pop(0)
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def fake_open_session(**kwargs: object) -> AsyncIterator[object]:
|
|
session_options.append(kwargs)
|
|
yield session
|
|
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'post_fetch', fake_post_fetch)
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'open_session', fake_open_session)
|
|
search = censysearch.SearchCensys('example.com', limit=250)
|
|
|
|
await search.process(proxy=True)
|
|
|
|
assert [call['url'] for call in calls] == [
|
|
'https://api.platform.censys.io/v3/global/search/query',
|
|
'https://api.platform.censys.io/v3/global/search/query',
|
|
]
|
|
assert calls[0]['headers'] == {
|
|
'Accept': 'application/json',
|
|
'Authorization': 'Bearer platform-token',
|
|
}
|
|
assert calls[0]['params'] == {'organization_id': 'org-id'}
|
|
assert calls[0]['json_body'] == {
|
|
'query': 'cert.names: "example.com"',
|
|
'fields': ['cert.names', 'cert.parsed.subject.email_address'],
|
|
'page_size': 100,
|
|
}
|
|
assert calls[1]['json_body'] == {
|
|
**calls[0]['json_body'],
|
|
'page_token': 'next-page',
|
|
}
|
|
assert calls[0]['json'] is True
|
|
assert calls[0]['proxy'] is True
|
|
assert calls[0]['include_metadata'] is True
|
|
assert session_options == [
|
|
{
|
|
'headers': {'Accept': 'application/json', 'Authorization': 'Bearer platform-token'},
|
|
'proxy': True,
|
|
'request_timeout': 720,
|
|
}
|
|
]
|
|
assert all(call['session'] is session for call in calls)
|
|
assert await search.get_hostnames() == {'a.example.com', 'b.example.com'}
|
|
assert await search.get_emails() == {'admin@example.com', 'ops@example.com'}
|
|
assert search.execution_status == 'completed'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_setup_failure_reports_transport_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', 'org-id'))
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def failed_open_session(**_kwargs: object) -> AsyncIterator[object]:
|
|
raise OSError('sensitive provider detail')
|
|
yield object()
|
|
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'open_session', failed_open_session)
|
|
search = censysearch.SearchCensys('example.com')
|
|
|
|
await search.process()
|
|
|
|
assert search.execution_status == 'failed'
|
|
assert search.stop_reason == 'transport-error'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unexpected_adapter_failure_is_not_misclassified(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', 'org-id'))
|
|
search = censysearch.SearchCensys('example.com')
|
|
|
|
async def failed_search() -> None:
|
|
raise RuntimeError('adapter defect')
|
|
|
|
monkeypatch.setattr(search, 'do_search', failed_search)
|
|
|
|
with pytest.raises(RuntimeError, match='adapter defect'):
|
|
await search.process()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_uses_free_wallet_and_respects_result_limit(monkeypatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', None))
|
|
calls: list[dict[str, object]] = []
|
|
|
|
async def fake_post_fetch(_url: str, **kwargs):
|
|
calls.append(kwargs)
|
|
return FetcherResponse(
|
|
{
|
|
'result': {
|
|
'hits': [{'certificate_v1': {'resource': {'names': [f'{index}.example.com']}}} for index in range(1, 5)],
|
|
'next_page_token': 'ignored-after-limit',
|
|
}
|
|
},
|
|
200,
|
|
{},
|
|
)
|
|
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'post_fetch', fake_post_fetch)
|
|
search = censysearch.SearchCensys('example.com', limit=3)
|
|
|
|
await search.process()
|
|
|
|
assert calls[0]['params'] == ''
|
|
assert calls[0]['json_body']['page_size'] == 3
|
|
assert await search.get_hostnames() == {'1.example.com', '2.example.com', '3.example.com'}
|
|
assert len(calls) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_accepts_missing_terminal_page_token(monkeypatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', None))
|
|
|
|
async def fake_post_fetch(*_args, **_kwargs):
|
|
return FetcherResponse({'result': {'hits': []}}, 200, {})
|
|
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'post_fetch', fake_post_fetch)
|
|
search = censysearch.SearchCensys('example.com')
|
|
|
|
await search.process()
|
|
|
|
assert search.execution_status == 'completed'
|
|
assert search.stop_reason == 'no-results'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_limit_hit_is_partial_when_it_contains_evidence(monkeypatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', None))
|
|
|
|
async def fake_post_fetch(*_args, **_kwargs):
|
|
return FetcherResponse(
|
|
{
|
|
'result': {
|
|
'hits': [{'certificate_v1': {'resource': {'names': ['a.example.com'], 'parsed': 'bad'}}}],
|
|
'next_page_token': 'unused',
|
|
}
|
|
},
|
|
200,
|
|
{},
|
|
)
|
|
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'post_fetch', fake_post_fetch)
|
|
search = censysearch.SearchCensys('example.com', limit=1)
|
|
|
|
await search.process()
|
|
|
|
assert await search.get_hostnames() == {'a.example.com'}
|
|
assert search.execution_status == 'partial'
|
|
assert search.stop_reason == 'invalid-response'
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('response', 'expected_status', 'expected_reason'),
|
|
[
|
|
(FetcherResponse(None, 401, {}), 'failed', 'access-denied'),
|
|
(FetcherResponse(None, 429, {}), 'rate-limited', 'http-429'),
|
|
(FetcherResponse({'result': {'hits': 'bad'}}, 200, {}), 'failed', 'invalid-response'),
|
|
(None, 'failed', 'transport-error'),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_search_reports_provider_failures_truthfully(
|
|
monkeypatch, response: FetcherResponse | None, expected_status: str, expected_reason: str
|
|
) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', None))
|
|
|
|
async def fake_post_fetch(*_args, **_kwargs):
|
|
return response
|
|
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'post_fetch', fake_post_fetch)
|
|
search = censysearch.SearchCensys('example.com')
|
|
|
|
await search.process()
|
|
|
|
assert search.execution_status == expected_status
|
|
assert search.stop_reason == expected_reason
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_keeps_event_loop_responsive_and_propagates_cancellation(monkeypatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', None))
|
|
request_started = asyncio.Event()
|
|
release_request = asyncio.Event()
|
|
|
|
async def fake_post_fetch(*_args, **_kwargs):
|
|
request_started.set()
|
|
await release_request.wait()
|
|
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'post_fetch', fake_post_fetch)
|
|
task = asyncio.create_task(censysearch.SearchCensys('example.com').process())
|
|
await request_started.wait()
|
|
callback_ran = asyncio.Event()
|
|
asyncio.get_running_loop().call_soon(callback_ran.set)
|
|
|
|
await asyncio.wait_for(callback_ran.wait(), timeout=0.1)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_classifies_transport_exceptions(monkeypatch) -> None:
|
|
monkeypatch.setattr(censysearch.Core, 'censys_key', lambda: ('platform-token', None))
|
|
|
|
async def fake_post_fetch(*_args, **_kwargs):
|
|
raise OSError('offline')
|
|
|
|
monkeypatch.setattr(censysearch.AsyncFetcher, 'post_fetch', fake_post_fetch)
|
|
search = censysearch.SearchCensys('example.com')
|
|
|
|
await search.process()
|
|
|
|
assert search.execution_status == 'failed'
|
|
assert search.stop_reason == 'transport-error'
|
|
|
|
|
|
def test_deprecated_censys_sdk_is_not_a_runtime_dependency() -> None:
|
|
assert '"censys==' not in Path('pyproject.toml').read_text()
|