mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-17 19:35:40 +02:00
503 lines
17 KiB
Python
503 lines
17 KiB
Python
import asyncio
|
|
import contextlib
|
|
from collections.abc import AsyncIterator
|
|
from datetime import UTC, datetime, timedelta
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from theHarvester.discovery import urlscan
|
|
from theHarvester.lib.core import FetcherResponse
|
|
|
|
|
|
class ProviderSession:
|
|
def __init__(self) -> None:
|
|
self.exited = False
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def provider_session(monkeypatch: pytest.MonkeyPatch) -> ProviderSession:
|
|
session = ProviderSession()
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def fake_open_session(**_kwargs: Any) -> AsyncIterator[object]:
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.exited = True
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'open_session', fake_open_session)
|
|
return session
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_collects_sequential_pages_and_preserves_all_routes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
provider_session: ProviderSession,
|
|
) -> None:
|
|
responses = [
|
|
FetcherResponse(
|
|
body={
|
|
'results': [
|
|
{
|
|
'page': {
|
|
'domain': 'first.example.com',
|
|
'ip': '192.0.2.10',
|
|
'url': 'https://first.example.com/path',
|
|
'asn': 'AS64496',
|
|
'asnname': 'Example Transit One',
|
|
},
|
|
'sort': [200, 'first'],
|
|
}
|
|
]
|
|
},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
FetcherResponse(
|
|
body={
|
|
'results': [
|
|
{
|
|
'page': {
|
|
'domain': 'second.example.com',
|
|
'ip': '2001:db8::10',
|
|
'url': 'https://second.example.com/',
|
|
'asn': 'AS64497',
|
|
'asnname': 'Example Transit Two',
|
|
},
|
|
'sort': [100, 'second'],
|
|
}
|
|
]
|
|
},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
]
|
|
calls: list[dict[str, Any]] = []
|
|
|
|
async def fake_fetch(**kwargs: Any) -> FetcherResponse:
|
|
calls.append(kwargs)
|
|
return responses.pop(0)
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 2)
|
|
|
|
await search.process(proxy=True)
|
|
|
|
assert await search.get_hostnames() == {'first.example.com', 'second.example.com'}
|
|
assert await search.get_ips() == {'192.0.2.10', '2001:db8::10'}
|
|
assert await search.get_urls() == {
|
|
'https://first.example.com/path',
|
|
'https://second.example.com/',
|
|
}
|
|
assert await search.get_asns() == {'AS64496', 'AS64497'}
|
|
assert {
|
|
(
|
|
observation.asn,
|
|
observation.organization_label,
|
|
observation.subject_kind,
|
|
observation.subject_value,
|
|
)
|
|
for observation in await search.get_asn_attributions()
|
|
} == {
|
|
('AS64496', 'Example Transit One', 'hostname', 'first.example.com'),
|
|
('AS64496', 'Example Transit One', 'ip', '192.0.2.10'),
|
|
('AS64497', 'Example Transit Two', 'hostname', 'second.example.com'),
|
|
('AS64497', 'Example Transit Two', 'ip', '2001:db8::10'),
|
|
}
|
|
assert [call['params'] for call in calls] == [
|
|
{'q': 'domain:example.com', 'size': 2},
|
|
{'q': 'domain:example.com', 'size': 1, 'search_after': '200,first'},
|
|
]
|
|
assert all(call['url'] == 'https://urlscan.io/api/v1/search/' for call in calls)
|
|
assert all(call['session'] is provider_session for call in calls)
|
|
assert all(call['json'] is True for call in calls)
|
|
assert all(call['include_metadata'] is True for call in calls)
|
|
assert all('request_timeout' not in call for call in calls)
|
|
assert provider_session.exited is True
|
|
assert search.execution_status == 'completed'
|
|
assert search.stop_reason is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_asn_relationship_is_retained_once_per_source_run(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
moments = iter((datetime(2026, 8, 12, tzinfo=UTC), datetime(2026, 8, 12, tzinfo=UTC) + timedelta(seconds=1)))
|
|
|
|
class TickingDateTime:
|
|
@classmethod
|
|
def now(cls, timezone: object) -> datetime:
|
|
assert timezone is UTC
|
|
return next(moments)
|
|
|
|
page = {
|
|
'domain': 'api.example.com',
|
|
'ip': '192.0.2.10',
|
|
'url': 'https://api.example.com/',
|
|
'asn': 'AS64496',
|
|
'asnname': 'Example Transit',
|
|
}
|
|
responses = [
|
|
FetcherResponse(body={'results': [{'page': page, 'sort': [2, 'first']}]}, status=200, headers={}),
|
|
FetcherResponse(body={'results': [{'page': page, 'sort': [1, 'second']}]}, status=200, headers={}),
|
|
FetcherResponse(body={'results': []}, status=200, headers={}),
|
|
]
|
|
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
return responses.pop(0)
|
|
|
|
monkeypatch.setattr(urlscan, 'datetime', TickingDateTime)
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert len(await search.get_asn_attributions()) == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_empty_response_is_completed(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
return FetcherResponse(body={'results': []}, status=200, headers={})
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert await search.get_hostnames() == set()
|
|
assert await search.get_ips() == set()
|
|
assert await search.get_urls() == set()
|
|
assert await search.get_asns() == set()
|
|
assert search.execution_status == 'completed'
|
|
assert search.stop_reason == 'no-results'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_optional_fields_are_skipped(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
responses = [
|
|
FetcherResponse(
|
|
body={'results': [{'sort': [2, 'missing-page']}, {'page': {}, 'sort': [1, 'empty-page']}]},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
FetcherResponse(body={'results': []}, status=200, headers={}),
|
|
]
|
|
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
return responses.pop(0)
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert await search.get_hostnames() == set()
|
|
assert search.execution_status == 'completed'
|
|
assert search.stop_reason == 'no-results'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_nested_fields_preserve_valid_partial_results(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
responses = [
|
|
FetcherResponse(
|
|
body={
|
|
'results': [
|
|
{'page': {'domain': 'valid.example.com'}, 'sort': [2, 'valid']},
|
|
{'page': {'domain': 7, 'ip': None, 'url': [], 'asn': {}}, 'sort': [1, 'malformed']},
|
|
]
|
|
},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
FetcherResponse(body={'results': []}, status=200, headers={}),
|
|
]
|
|
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
return responses.pop(0)
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert await search.get_hostnames() == {'valid.example.com'}
|
|
assert await search.get_ips() == set()
|
|
assert search.execution_status == 'partial'
|
|
assert search.stop_reason == 'invalid-response'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_results_are_typed_and_scoped_before_insertion(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
responses = [
|
|
FetcherResponse(
|
|
body={
|
|
'results': [
|
|
{
|
|
'page': {
|
|
'domain': 'API.Example.COM.',
|
|
'ip': '2001:0db8::10',
|
|
'url': 'https://portal.example.com/path',
|
|
'asn': 'as64496',
|
|
},
|
|
'sort': [2, 'valid'],
|
|
},
|
|
{
|
|
'page': {
|
|
'domain': 'notexample.com',
|
|
'ip': '198.51.100.20',
|
|
'url': 'https://outside.test/?q=example.com',
|
|
'asn': 'AS64497',
|
|
},
|
|
'sort': [1, 'rejected'],
|
|
},
|
|
]
|
|
},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
FetcherResponse(body={'results': []}, status=200, headers={}),
|
|
]
|
|
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
return responses.pop(0)
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert await search.get_hostnames() == {'api.example.com'}
|
|
assert await search.get_ips() == {'2001:db8::10'}
|
|
assert await search.get_urls() == {'https://portal.example.com/path'}
|
|
assert await search.get_asns() == {'AS64496'}
|
|
assert search.execution_status == 'completed'
|
|
assert search.stop_reason is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('response', 'execution_status', 'stop_reason'),
|
|
[
|
|
(None, 'failed', 'transport-error'),
|
|
(FetcherResponse(body={}, status=401, headers={}), 'failed', 'access-denied'),
|
|
(FetcherResponse(body={}, status=403, headers={}), 'failed', 'access-denied'),
|
|
(FetcherResponse(body={}, status=429, headers={}), 'rate-limited', 'http-429'),
|
|
(FetcherResponse(body={}, status=503, headers={}), 'failed', 'http-503'),
|
|
(FetcherResponse(body=[], status=200, headers={}), 'failed', 'invalid-response'),
|
|
(FetcherResponse(body={'results': {}}, status=200, headers={}), 'failed', 'invalid-response'),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_failed_first_page_is_attributed(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
response: FetcherResponse | None,
|
|
execution_status: str,
|
|
stop_reason: str,
|
|
) -> None:
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse | None:
|
|
return response
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert search.execution_status == execution_status
|
|
assert search.stop_reason == stop_reason
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('second_response', 'stop_reason'),
|
|
[
|
|
(None, 'transport-error'),
|
|
(FetcherResponse(body={}, status=403, headers={}), 'access-denied'),
|
|
(FetcherResponse(body={}, status=429, headers={}), 'http-429'),
|
|
(FetcherResponse(body={}, status=503, headers={}), 'http-503'),
|
|
(FetcherResponse(body={'results': {}}, status=200, headers={}), 'invalid-response'),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_later_failure_preserves_partial_results(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
second_response: FetcherResponse | None,
|
|
stop_reason: str,
|
|
) -> None:
|
|
responses = [
|
|
FetcherResponse(
|
|
body={'results': [{'page': {'domain': 'first.example.com'}, 'sort': [1, 'first']}]},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
second_response,
|
|
]
|
|
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse | None:
|
|
return responses.pop(0)
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert await search.get_hostnames() == {'first.example.com'}
|
|
assert search.execution_status == 'partial'
|
|
assert search.stop_reason == stop_reason
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_exception_is_transport_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
raise OSError('private transport details')
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert search.execution_status == 'failed'
|
|
assert search.stop_reason == 'transport-error'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_cursor_stops_after_first_page(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls = 0
|
|
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
nonlocal calls
|
|
calls += 1
|
|
return FetcherResponse(
|
|
body={'results': [{'page': {'domain': 'first.example.com'}}]},
|
|
status=200,
|
|
headers={},
|
|
)
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert calls == 1
|
|
assert await search.get_hostnames() == {'first.example.com'}
|
|
assert search.execution_status == 'partial'
|
|
assert search.stop_reason == 'invalid-cursor'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_cursor_stops_without_a_third_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
responses = [
|
|
FetcherResponse(
|
|
body={'results': [{'page': {'domain': 'first.example.com'}, 'sort': [1, 'same']}]},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
FetcherResponse(
|
|
body={'results': [{'page': {'domain': 'second.example.com'}, 'sort': [1, 'same']}]},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
]
|
|
calls = 0
|
|
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
nonlocal calls
|
|
calls += 1
|
|
return responses.pop(0)
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert calls == 2
|
|
assert await search.get_hostnames() == {'first.example.com', 'second.example.com'}
|
|
assert search.execution_status == 'partial'
|
|
assert search.stop_reason == 'repeated-cursor'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pagination_continues_beyond_the_removed_local_page_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[dict[str, Any]] = []
|
|
first_page = [
|
|
{'page': {'domain': f'page-{index}.example.com'}, 'sort': [10_001 - index, f'cursor-{index}']}
|
|
for index in range(1, 10_001)
|
|
]
|
|
responses = [
|
|
FetcherResponse(body={'results': first_page}, status=200, headers={}),
|
|
FetcherResponse(
|
|
body={
|
|
'results': [
|
|
{
|
|
'page': {'domain': 'page-10001.example.com'},
|
|
'sort': [0, 'cursor-10001'],
|
|
}
|
|
]
|
|
},
|
|
status=200,
|
|
headers={},
|
|
),
|
|
]
|
|
|
|
async def fake_fetch(**kwargs: Any) -> FetcherResponse:
|
|
calls.append(kwargs['params'])
|
|
return responses.pop(0)
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10_001)
|
|
|
|
await search.process()
|
|
|
|
assert calls == [
|
|
{'q': 'domain:example.com', 'size': 10_000},
|
|
{'q': 'domain:example.com', 'size': 1, 'search_after': '1,cursor-10000'},
|
|
]
|
|
assert await search.get_hostnames() == {f'page-{page}.example.com' for page in range(1, 10_002)}
|
|
assert search.execution_status == 'completed'
|
|
assert search.stop_reason is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_operator_limit_sets_page_size_and_stops_without_an_extra_request(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[dict[str, Any]] = []
|
|
results = [
|
|
{'page': {'domain': f'result-{index}.example.com'}, 'sort': [10 - index, f'cursor-{index}']} for index in range(10)
|
|
]
|
|
|
|
async def fake_fetch(**kwargs: Any) -> FetcherResponse:
|
|
calls.append(kwargs['params'])
|
|
return FetcherResponse(body={'results': results}, status=200, headers={})
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
search = urlscan.SearchUrlscan('example.com', 10)
|
|
|
|
await search.process()
|
|
|
|
assert calls == [{'q': 'domain:example.com', 'size': 10}]
|
|
assert len(await search.get_hostnames()) == 10
|
|
assert search.execution_status == 'completed'
|
|
assert search.stop_reason is None
|
|
|
|
|
|
@pytest.mark.parametrize('limit', [0, -1, True, 1.5])
|
|
def test_limit_must_be_a_positive_integer(limit: Any) -> None:
|
|
with pytest.raises(ValueError, match='positive integer'):
|
|
urlscan.SearchUrlscan('example.com', limit)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancellation_propagates(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
provider_session: ProviderSession,
|
|
) -> None:
|
|
async def fake_fetch(**_kwargs: Any) -> FetcherResponse:
|
|
raise asyncio.CancelledError
|
|
|
|
monkeypatch.setattr(urlscan.AsyncFetcher, 'fetch', fake_fetch)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await urlscan.SearchUrlscan('example.com', 10).process()
|
|
assert provider_session.exited is True
|
|
|
|
|
|
pytestmark = pytest.mark.provider_contract('urlscan')
|