mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-17 19:35:40 +02:00
fix: report provider collection outcomes truthfully
This commit is contained in:
+237
-39
@@ -1,3 +1,5 @@
|
|||||||
|
import contextlib
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
@@ -6,6 +8,8 @@ import pytest
|
|||||||
from theHarvester.discovery import bravesearch
|
from theHarvester.discovery import bravesearch
|
||||||
from theHarvester.discovery.constants import MissingKey
|
from theHarvester.discovery.constants import MissingKey
|
||||||
from theHarvester.lib.configuration import InMemoryCredentialAdapter
|
from theHarvester.lib.configuration import InMemoryCredentialAdapter
|
||||||
|
from theHarvester.lib.core import FetcherResponse, ResponseStreamError
|
||||||
|
from theHarvester.lib.source_execution import SourceExecutionReport
|
||||||
|
|
||||||
|
|
||||||
def _result(index: int) -> dict[str, str]:
|
def _result(index: int) -> dict[str, str]:
|
||||||
@@ -16,11 +20,15 @@ def _result(index: int) -> dict[str, str]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _response(results: list[dict[str, str]], *, more: bool) -> dict[str, Any]:
|
def _response(results: list[dict[str, str]], *, more: bool) -> FetcherResponse:
|
||||||
return {
|
return FetcherResponse(
|
||||||
'query': {'more_results_available': more},
|
{
|
||||||
'web': {'results': results},
|
'query': {'more_results_available': more},
|
||||||
}
|
'web': {'results': results},
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -58,43 +66,210 @@ async def test_brave_normalizes_in_scope_evidence(
|
|||||||
],
|
],
|
||||||
more=False,
|
more=False,
|
||||||
),
|
),
|
||||||
{'error': {'message': 'Access denied', 'code': 'forbidden'}},
|
FetcherResponse({'error': {'message': 'Access denied', 'code': 'forbidden'}}, 200, {}),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
proxies: list[bool] = []
|
request_proxies: list[bool | None] = []
|
||||||
|
session_options: list[dict[str, Any]] = []
|
||||||
|
|
||||||
async def fake_fetch(*, url: str, **kwargs: Any) -> dict[str, Any]:
|
@contextlib.asynccontextmanager
|
||||||
proxies.append(kwargs['proxy'])
|
async def fake_open_session(**kwargs: Any) -> AsyncIterator[object]:
|
||||||
|
session_options.append(kwargs)
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
async def fake_fetch(*_args: Any, **kwargs: Any) -> FetcherResponse:
|
||||||
|
request_proxies.append(kwargs.get('proxy'))
|
||||||
return next(responses)
|
return next(responses)
|
||||||
|
|
||||||
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', fake_fetch)
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'open_session', fake_open_session)
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
await search.process(proxy=True)
|
await search.process(proxy=True)
|
||||||
|
|
||||||
assert proxies == [True, True]
|
assert session_options == [{'headers': search_headers(search), 'proxy': True, 'request_timeout': 60}]
|
||||||
|
assert request_proxies == [None, None]
|
||||||
assert await search.get_emails() == {'admin@example.com'}
|
assert await search.get_emails() == {'admin@example.com'}
|
||||||
assert await search.get_hostnames() == ['blog.example.com', 'example.com']
|
assert await search.get_hostnames() == ['blog.example.com', 'example.com']
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('response', [None, []], ids=['empty', 'malformed'])
|
def search_headers(search: bravesearch.SearchBrave) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Accept-Encoding': 'gzip',
|
||||||
|
'X-Subscription-Token': search.api_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('failure_point', ['open', 'close'])
|
||||||
|
async def test_brave_reports_session_lifecycle_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
|
failure_point: str,
|
||||||
|
) -> None:
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def failed_open_session(**_kwargs: Any) -> AsyncIterator[object]:
|
||||||
|
if failure_point == 'open':
|
||||||
|
raise OSError('session creation failed')
|
||||||
|
yield object()
|
||||||
|
raise OSError('session close failed')
|
||||||
|
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
return _response([], more=False)
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'open_session', failed_open_session)
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
|
assert await search.process() == SourceExecutionReport('failed', 'transport-error')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_brave_does_not_misclassify_value_errors_as_transport_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
|
) -> None:
|
||||||
|
@contextlib.asynccontextmanager
|
||||||
|
async def failed_open_session(**_kwargs: Any) -> AsyncIterator[object]:
|
||||||
|
raise ValueError('programming defect')
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'open_session', failed_open_session)
|
||||||
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match='programming defect'):
|
||||||
|
await search.process()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('body', [None, []], ids=['empty', 'malformed'])
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_brave_unusable_response_returns_no_evidence(
|
async def test_brave_unusable_response_returns_no_evidence(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
brave_credentials: InMemoryCredentialAdapter,
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
response: list[Any] | None,
|
body: list[Any] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async def fake_fetch(*, url: str, **_kwargs: Any) -> list[Any] | None:
|
async def fake_fetch(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
return response
|
return FetcherResponse(body, 200, {})
|
||||||
|
|
||||||
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', fake_fetch)
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
await search.process()
|
report = await search.process()
|
||||||
|
|
||||||
assert await search.get_emails() == set()
|
assert await search.get_emails() == set()
|
||||||
assert await search.get_hostnames() == []
|
assert await search.get_hostnames() == []
|
||||||
|
assert report == SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('response', 'expected_report'),
|
||||||
|
[
|
||||||
|
(FetcherResponse(None, 401, {}), SourceExecutionReport('failed', 'access-denied')),
|
||||||
|
(FetcherResponse(None, 429, {}), SourceExecutionReport('rate-limited', 'http-429')),
|
||||||
|
(FetcherResponse(None, 503, {}), SourceExecutionReport('failed', 'http-503')),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_brave_reports_http_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
|
response: FetcherResponse,
|
||||||
|
expected_report: SourceExecutionReport,
|
||||||
|
) -> None:
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def legacy_fetch(*_args: Any, **_kwargs: Any) -> None:
|
||||||
|
raise AssertionError('Brave must use the bounded fetch_json seam')
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', legacy_fetch)
|
||||||
|
|
||||||
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
|
assert await search.process() == expected_report
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('reason', ['transport-error', 'invalid-response', 'response-limit'])
|
||||||
|
async def test_brave_reports_bounded_fetch_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
raise ResponseStreamError(reason) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
|
assert await search.process() == SourceExecutionReport('failed', reason)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_brave_propagates_cancellation(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
|
) -> None:
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
raise bravesearch.asyncio.CancelledError
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
|
with pytest.raises(bravesearch.asyncio.CancelledError):
|
||||||
|
await search.process()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
'body',
|
||||||
|
[
|
||||||
|
{},
|
||||||
|
{'web': []},
|
||||||
|
{'web': {'results': 'invalid'}, 'query': {'more_results_available': False}},
|
||||||
|
{'web': {'results': []}, 'query': {}},
|
||||||
|
{'web': {'results': ['invalid']}, 'query': {'more_results_available': False}},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_brave_reports_malformed_payloads(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
|
body: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
return FetcherResponse(body, 200, {})
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
|
assert await search.process() == SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('field', ['title', 'description', 'url'])
|
||||||
|
async def test_brave_rejects_non_string_evidence_fields(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
|
field: str,
|
||||||
|
) -> None:
|
||||||
|
result: dict[str, object] = _result(1)
|
||||||
|
result[field] = {'unexpected': 'host.example.com'}
|
||||||
|
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
return FetcherResponse(
|
||||||
|
{'web': {'results': [result]}, 'query': {'more_results_available': False}},
|
||||||
|
200,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
|
assert await search.process() == SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
assert search.totalresults == ''
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -110,11 +285,12 @@ async def test_brave_uses_page_offsets_and_one_global_limit(
|
|||||||
)
|
)
|
||||||
requests: list[dict[str, list[str]]] = []
|
requests: list[dict[str, list[str]]] = []
|
||||||
|
|
||||||
async def fake_fetch(*, url: str, **_kwargs: Any) -> dict[str, Any]:
|
async def fake_fetch(*_args: Any, **kwargs: Any) -> FetcherResponse:
|
||||||
|
url = kwargs.get('url', _args[0] if _args else '')
|
||||||
requests.append(parse_qs(urlparse(url).query))
|
requests.append(parse_qs(urlparse(url).query))
|
||||||
return next(responses)
|
return next(responses)
|
||||||
|
|
||||||
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', fake_fetch)
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
search = bravesearch.SearchBrave('example.com', 25, credential_adapter=brave_credentials)
|
search = bravesearch.SearchBrave('example.com', 25, credential_adapter=brave_credentials)
|
||||||
await search.process()
|
await search.process()
|
||||||
|
|
||||||
@@ -156,11 +332,12 @@ async def test_brave_requests_another_page_only_when_available(
|
|||||||
)
|
)
|
||||||
requests: list[dict[str, list[str]]] = []
|
requests: list[dict[str, list[str]]] = []
|
||||||
|
|
||||||
async def fake_fetch(*, url: str, **_kwargs: Any) -> dict[str, Any]:
|
async def fake_fetch(*_args: Any, **kwargs: Any) -> FetcherResponse:
|
||||||
|
url = kwargs.get('url', _args[0] if _args else '')
|
||||||
requests.append(parse_qs(urlparse(url).query))
|
requests.append(parse_qs(urlparse(url).query))
|
||||||
return next(responses, _response([], more=False))
|
return next(responses, _response([], more=False))
|
||||||
|
|
||||||
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', fake_fetch)
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
search = bravesearch.SearchBrave('example.com', 10, credential_adapter=brave_credentials)
|
||||||
await search.process()
|
await search.process()
|
||||||
|
|
||||||
@@ -183,11 +360,12 @@ async def test_brave_continues_sparse_pages_while_more_results_are_available(
|
|||||||
)
|
)
|
||||||
requests: list[dict[str, list[str]]] = []
|
requests: list[dict[str, list[str]]] = []
|
||||||
|
|
||||||
async def fake_fetch(*, url: str, **_kwargs: Any) -> dict[str, Any]:
|
async def fake_fetch(*_args: Any, **kwargs: Any) -> FetcherResponse:
|
||||||
|
url = kwargs.get('url', _args[0] if _args else '')
|
||||||
requests.append(parse_qs(urlparse(url).query))
|
requests.append(parse_qs(urlparse(url).query))
|
||||||
return next(responses)
|
return next(responses)
|
||||||
|
|
||||||
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', fake_fetch)
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
search = bravesearch.SearchBrave('example.com', 20, credential_adapter=brave_credentials)
|
search = bravesearch.SearchBrave('example.com', 20, credential_adapter=brave_credentials)
|
||||||
await search.process()
|
await search.process()
|
||||||
|
|
||||||
@@ -198,6 +376,23 @@ async def test_brave_continues_sparse_pages_while_more_results_are_available(
|
|||||||
assert len(search.results) == 20
|
assert len(search.results) == 20
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_brave_rejects_empty_page_that_claims_more_results(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
|
) -> None:
|
||||||
|
async def fake_fetch(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
return _response([], more=True)
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
|
search = bravesearch.SearchBrave('example.com', 20, credential_adapter=brave_credentials)
|
||||||
|
|
||||||
|
report = await search.process()
|
||||||
|
|
||||||
|
assert report == SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
assert search.results == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_brave_stops_after_an_exact_full_page(
|
async def test_brave_stops_after_an_exact_full_page(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -205,15 +400,17 @@ async def test_brave_stops_after_an_exact_full_page(
|
|||||||
) -> None:
|
) -> None:
|
||||||
requests: list[dict[str, list[str]]] = []
|
requests: list[dict[str, list[str]]] = []
|
||||||
|
|
||||||
async def fake_fetch(*, url: str, **_kwargs: Any) -> dict[str, Any]:
|
async def fake_fetch(*_args: Any, **kwargs: Any) -> FetcherResponse:
|
||||||
|
url = kwargs.get('url', _args[0] if _args else '')
|
||||||
requests.append(parse_qs(urlparse(url).query))
|
requests.append(parse_qs(urlparse(url).query))
|
||||||
return _response([_result(index) for index in range(20)], more=True)
|
return _response([_result(index) for index in range(20)], more=True)
|
||||||
|
|
||||||
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', fake_fetch)
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
search = bravesearch.SearchBrave('example.com', 20, credential_adapter=brave_credentials)
|
search = bravesearch.SearchBrave('example.com', 20, credential_adapter=brave_credentials)
|
||||||
await search.process()
|
report = await search.process()
|
||||||
|
|
||||||
assert [(request['offset'], request['count']) for request in requests] == [(['0'], ['20'])]
|
assert [(request['offset'], request['count']) for request in requests] == [(['0'], ['20'])]
|
||||||
|
assert report == SourceExecutionReport('completed', 'result-limit')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -223,42 +420,43 @@ async def test_brave_rate_limit_does_not_skip_to_the_next_page(
|
|||||||
) -> None:
|
) -> None:
|
||||||
responses = iter(
|
responses = iter(
|
||||||
[
|
[
|
||||||
{'error': {'message': 'Rate limit exceeded', 'code': 'rate_limit_exceeded'}},
|
FetcherResponse({'error': {'message': 'Rate limit exceeded', 'code': 'rate_limit_exceeded'}}, 200, {}),
|
||||||
_response([], more=False),
|
_response([], more=False),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
requests: list[dict[str, list[str]]] = []
|
requests: list[dict[str, list[str]]] = []
|
||||||
|
|
||||||
async def fake_fetch(*, url: str, **_kwargs: Any) -> dict[str, Any]:
|
async def fake_fetch(*_args: Any, **kwargs: Any) -> FetcherResponse:
|
||||||
|
url = kwargs.get('url', _args[0] if _args else '')
|
||||||
requests.append(parse_qs(urlparse(url).query))
|
requests.append(parse_qs(urlparse(url).query))
|
||||||
return next(responses)
|
return next(responses)
|
||||||
|
|
||||||
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', fake_fetch)
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
search = bravesearch.SearchBrave('example.com', 40, credential_adapter=brave_credentials)
|
search = bravesearch.SearchBrave('example.com', 40, credential_adapter=brave_credentials)
|
||||||
await search.process()
|
report = await search.process()
|
||||||
|
|
||||||
assert [(request['q'], request['offset']) for request in requests] == [
|
assert [(request['q'], request['offset']) for request in requests] == [(['"example.com"'], ['0'])]
|
||||||
(['"example.com"'], ['0']),
|
assert report == SourceExecutionReport('rate-limited', 'provider-rate-limit')
|
||||||
(['site:example.com'], ['0']),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_brave_never_exceeds_maximum_page_offset(
|
async def test_brave_reports_maximum_page_offset_as_truncation(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
brave_credentials: InMemoryCredentialAdapter,
|
brave_credentials: InMemoryCredentialAdapter,
|
||||||
) -> None:
|
) -> None:
|
||||||
requests: list[dict[str, list[str]]] = []
|
requests: list[dict[str, list[str]]] = []
|
||||||
|
|
||||||
async def fake_fetch(*, url: str, **_kwargs: Any) -> dict[str, Any]:
|
async def fake_fetch(*_args: Any, **kwargs: Any) -> FetcherResponse:
|
||||||
|
url = kwargs.get('url', _args[0] if _args else '')
|
||||||
requests.append(parse_qs(urlparse(url).query))
|
requests.append(parse_qs(urlparse(url).query))
|
||||||
return _response([_result(len(requests))], more=True)
|
return _response([_result(len(requests))], more=True)
|
||||||
|
|
||||||
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch', fake_fetch)
|
monkeypatch.setattr(bravesearch.AsyncFetcher, 'fetch_json', fake_fetch)
|
||||||
search = bravesearch.SearchBrave('example.com', 1_000, credential_adapter=brave_credentials)
|
search = bravesearch.SearchBrave('example.com', 1_000, credential_adapter=brave_credentials)
|
||||||
await search.process()
|
report = await search.process()
|
||||||
|
|
||||||
assert [request['offset'] for request in requests] == [[str(offset)] for offset in range(10)] * 2
|
assert [request['offset'] for request in requests] == [[str(offset)] for offset in range(10)]
|
||||||
|
assert report == SourceExecutionReport('partial', 'pagination-limit')
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.provider_contract('brave')
|
pytestmark = pytest.mark.provider_contract('brave')
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ import pytest
|
|||||||
|
|
||||||
from theHarvester.discovery import dnsdb
|
from theHarvester.discovery import dnsdb
|
||||||
from theHarvester.discovery.constants import MissingKey
|
from theHarvester.discovery.constants import MissingKey
|
||||||
|
from theHarvester.lib.source_execution import SourceExecutionReport
|
||||||
|
|
||||||
|
|
||||||
def _install_response(
|
def _install_response(
|
||||||
@@ -108,10 +110,14 @@ async def test_process_uses_configured_proxy_when_enabled(monkeypatch: pytest.Mo
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
('last_line', 'expected_message'),
|
('last_line', 'expected_message', 'expected_report'),
|
||||||
[
|
[
|
||||||
(b'{"cond":"limited"}\n', 'ended with limited'),
|
(
|
||||||
(b'not-json\n', 'malformed NDJSON'),
|
b'{"cond":"limited"}\n',
|
||||||
|
'ended with limited',
|
||||||
|
SourceExecutionReport('rate-limited', 'provider-limited'),
|
||||||
|
),
|
||||||
|
(b'not-json\n', 'malformed NDJSON', SourceExecutionReport('failed', 'invalid-response')),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_process_preserves_partial_results(
|
async def test_process_preserves_partial_results(
|
||||||
@@ -119,6 +125,7 @@ async def test_process_preserves_partial_results(
|
|||||||
caplog: pytest.LogCaptureFixture,
|
caplog: pytest.LogCaptureFixture,
|
||||||
last_line: bytes,
|
last_line: bytes,
|
||||||
expected_message: str,
|
expected_message: str,
|
||||||
|
expected_report: SourceExecutionReport,
|
||||||
) -> None:
|
) -> None:
|
||||||
caplog.set_level(logging.INFO, logger=dnsdb.__name__)
|
caplog.set_level(logging.INFO, logger=dnsdb.__name__)
|
||||||
monkeypatch.setattr(dnsdb.Core, 'dnsdb_key', lambda: 'dnsdb-test-key')
|
monkeypatch.setattr(dnsdb.Core, 'dnsdb_key', lambda: 'dnsdb-test-key')
|
||||||
@@ -132,12 +139,53 @@ async def test_process_preserves_partial_results(
|
|||||||
)
|
)
|
||||||
|
|
||||||
search = dnsdb.SearchDNSDB('example.com')
|
search = dnsdb.SearchDNSDB('example.com')
|
||||||
await search.process()
|
report = await search.process()
|
||||||
|
|
||||||
assert await search.get_hostnames() == {'first.example.com'}
|
assert await search.get_hostnames() == {'first.example.com'}
|
||||||
|
assert report == expected_report
|
||||||
assert any(expected_message in message for message in caplog.messages)
|
assert any(expected_message in message for message in caplog.messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('lines', 'expected_report'),
|
||||||
|
[
|
||||||
|
((b'[]\n',), SourceExecutionReport('failed', 'invalid-response')),
|
||||||
|
((b'{"cond":"wrong"}\n',), SourceExecutionReport('failed', 'invalid-response')),
|
||||||
|
(
|
||||||
|
(b'{"cond":"begin"}\n', b'{"cond":"failed"}\n'),
|
||||||
|
SourceExecutionReport('failed', 'provider-failed'),
|
||||||
|
),
|
||||||
|
((b'{"cond":"begin"}\n',), SourceExecutionReport('failed', 'invalid-response')),
|
||||||
|
(
|
||||||
|
(b'{"cond":"begin"}\n', b'{"cond":"wrong"}\n', b'{"cond":"succeeded"}\n'),
|
||||||
|
SourceExecutionReport('failed', 'invalid-response'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(b'{"cond":"begin"}\n', b'{"obj":[]}\n', b'{"cond":"succeeded"}\n'),
|
||||||
|
SourceExecutionReport('failed', 'invalid-response'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(b'{"cond":"begin"}\n', b'{"obj":{}}\n', b'{"cond":"succeeded"}\n'),
|
||||||
|
SourceExecutionReport('failed', 'invalid-response'),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
(b'{"cond":"begin"}\n', b'{"obj":{"rrname":7}}\n', b'{"cond":"succeeded"}\n'),
|
||||||
|
SourceExecutionReport('failed', 'invalid-response'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_process_reports_abnormal_stream_termination(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
lines: tuple[bytes, ...],
|
||||||
|
expected_report: SourceExecutionReport,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(dnsdb.Core, 'dnsdb_key', lambda: 'dnsdb-test-key')
|
||||||
|
_install_response(monkeypatch, lines)
|
||||||
|
|
||||||
|
assert await dnsdb.SearchDNSDB('example.com').process() == expected_report
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_process_preserves_partial_results_on_midstream_timeout(
|
async def test_process_preserves_partial_results_on_midstream_timeout(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
@@ -155,31 +203,60 @@ async def test_process_preserves_partial_results_on_midstream_timeout(
|
|||||||
)
|
)
|
||||||
|
|
||||||
search = dnsdb.SearchDNSDB('example.com')
|
search = dnsdb.SearchDNSDB('example.com')
|
||||||
await search.process()
|
report = await search.process()
|
||||||
|
|
||||||
assert await search.get_hostnames() == {'first.example.com'}
|
assert await search.get_hostnames() == {'first.example.com'}
|
||||||
|
assert report == SourceExecutionReport('failed', 'transport-error')
|
||||||
assert any('request failed' in message for message in caplog.messages)
|
assert any('request failed' in message for message in caplog.messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_reports_deeply_nested_json_as_invalid_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(dnsdb.Core, 'dnsdb_key', lambda: 'dnsdb-test-key')
|
||||||
|
_install_response(monkeypatch, (b'{"cond":"begin"}\n', b'deeply-nested-json\n'))
|
||||||
|
json_loads = dnsdb.json.loads
|
||||||
|
|
||||||
|
def parse_record(line: str) -> object:
|
||||||
|
if line.strip() == 'deeply-nested-json':
|
||||||
|
raise RecursionError
|
||||||
|
return json_loads(line)
|
||||||
|
|
||||||
|
monkeypatch.setattr(dnsdb.json, 'loads', parse_record)
|
||||||
|
|
||||||
|
assert await dnsdb.SearchDNSDB('example.com').process() == SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_process_propagates_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(dnsdb.Core, 'dnsdb_key', lambda: 'dnsdb-test-key')
|
||||||
|
_install_response(
|
||||||
|
monkeypatch,
|
||||||
|
(b'{"cond":"begin"}\n',),
|
||||||
|
stream_error=asyncio.CancelledError(),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await dnsdb.SearchDNSDB('example.com').process()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
('status', 'expected_error'),
|
('status', 'expected_report'),
|
||||||
[
|
[
|
||||||
(401, PermissionError),
|
(401, SourceExecutionReport('failed', 'access-denied')),
|
||||||
(429, ConnectionError),
|
(429, SourceExecutionReport('rate-limited', 'http-429')),
|
||||||
(503, ConnectionError),
|
(503, SourceExecutionReport('failed', 'http-503')),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
async def test_process_exposes_http_failures(
|
async def test_process_reports_http_failures(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
status: int,
|
status: int,
|
||||||
expected_error: type[Exception],
|
expected_report: SourceExecutionReport,
|
||||||
) -> None:
|
) -> None:
|
||||||
monkeypatch.setattr(dnsdb.Core, 'dnsdb_key', lambda: 'dnsdb-test-key')
|
monkeypatch.setattr(dnsdb.Core, 'dnsdb_key', lambda: 'dnsdb-test-key')
|
||||||
_install_response(monkeypatch, (), status=status)
|
_install_response(monkeypatch, (), status=status)
|
||||||
|
|
||||||
with pytest.raises(expected_error):
|
assert await dnsdb.SearchDNSDB('example.com').process() == expected_report
|
||||||
await dnsdb.SearchDNSDB('example.com').process()
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.provider_contract('dnsdb')
|
pytestmark = pytest.mark.provider_contract('dnsdb')
|
||||||
|
|||||||
@@ -1,13 +1,45 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from theHarvester.discovery import duckduckgosearch
|
from theHarvester.discovery import duckduckgosearch
|
||||||
|
from theHarvester.lib.core import FetcherResponse, ResponseStreamError
|
||||||
|
from theHarvester.lib.source_execution import SourceExecutionReport
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('response', 'expected_report'),
|
||||||
|
[
|
||||||
|
(FetcherResponse(None, 401, {}), SourceExecutionReport('failed', 'access-denied')),
|
||||||
|
(FetcherResponse(None, 429, {}), SourceExecutionReport('rate-limited', 'http-429')),
|
||||||
|
(FetcherResponse(None, 503, {}), SourceExecutionReport('failed', 'http-503')),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_duckduckgo_reports_http_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
response: FetcherResponse,
|
||||||
|
expected_report: SourceExecutionReport,
|
||||||
|
) -> None:
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def legacy_fetch_all(*_args: Any, **_kwargs: Any) -> None:
|
||||||
|
raise AssertionError('DuckDuckGo must use the bounded fetch_json seam')
|
||||||
|
|
||||||
|
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_all', legacy_fetch_all)
|
||||||
|
|
||||||
|
search = duckduckgosearch.SearchDuckDuckGo('example.com', 100)
|
||||||
|
|
||||||
|
assert await search.process() == expected_report
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_duckduckgo_does_not_fetch_provider_returned_urls(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_duckduckgo_does_not_fetch_provider_returned_urls(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
requests: list[tuple[list[str], bool]] = []
|
requests: list[tuple[str, bool]] = []
|
||||||
payload = """
|
payload = """
|
||||||
{
|
{
|
||||||
"AbstractURL": "https://api.example.com",
|
"AbstractURL": "https://api.example.com",
|
||||||
@@ -16,21 +48,21 @@ async def test_duckduckgo_does_not_fetch_provider_returned_urls(monkeypatch: pyt
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def fake_fetch_all(
|
async def fake_fetch_json(
|
||||||
urls: list[str] | set[str],
|
url: str,
|
||||||
*,
|
*,
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
proxy: bool = False,
|
proxy: bool = False,
|
||||||
**_kwargs: Any,
|
**_kwargs: Any,
|
||||||
) -> list[str]:
|
) -> FetcherResponse:
|
||||||
requests.append((list(urls), proxy))
|
requests.append((url, proxy))
|
||||||
return [payload]
|
return FetcherResponse(json.loads(payload), 200, {})
|
||||||
|
|
||||||
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_all', fake_fetch_all)
|
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
search = duckduckgosearch.SearchDuckDuckGo('example.com', 100)
|
search = duckduckgosearch.SearchDuckDuckGo('example.com', 100)
|
||||||
await search.process(proxy=True)
|
await search.process(proxy=True)
|
||||||
|
|
||||||
assert requests == [(['https://api.duckduckgo.com/?q=example.com&format=json&pretty=1'], True)]
|
assert requests == [('https://api.duckduckgo.com/?q=example.com&format=json&pretty=1', True)]
|
||||||
assert await search.get_hostnames() == ['api.example.com', 'example.com']
|
assert await search.get_hostnames() == ['api.example.com', 'example.com']
|
||||||
assert await search.get_emails() == {'admin@example.com'}
|
assert await search.get_emails() == {'admin@example.com'}
|
||||||
|
|
||||||
@@ -41,24 +73,84 @@ async def test_duckduckgo_does_not_fetch_provider_returned_urls(monkeypatch: pyt
|
|||||||
'',
|
'',
|
||||||
'{"broken": ',
|
'{"broken": ',
|
||||||
'{"error": "Access denied", "url": "https://api.example.net"}',
|
'{"error": "Access denied", "url": "https://api.example.net"}',
|
||||||
|
'{"unexpected": "host.example.com"}',
|
||||||
|
'{"AbstractText": {"unexpected": "host.example.com"}}',
|
||||||
|
'{"Results": [{"FirstURL": {"unexpected": "host.example.com"}}]}',
|
||||||
],
|
],
|
||||||
ids=['empty', 'malformed', 'blocked'],
|
ids=['empty', 'malformed', 'blocked', 'unknown-schema', 'invalid-text-field', 'invalid-result-field'],
|
||||||
)
|
)
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_duckduckgo_unusable_response_returns_no_evidence(
|
async def test_duckduckgo_unusable_response_returns_no_evidence(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
payload: str,
|
payload: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
async def fake_fetch_all(urls: list[str] | set[str], **_kwargs: Any) -> list[str]:
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
return [payload]
|
if payload == '{"broken": ':
|
||||||
|
raise ResponseStreamError('invalid-response')
|
||||||
|
return FetcherResponse(json.loads(payload) if payload else None, 200, {})
|
||||||
|
|
||||||
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_all', fake_fetch_all)
|
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
search = duckduckgosearch.SearchDuckDuckGo('example.com', 100)
|
search = duckduckgosearch.SearchDuckDuckGo('example.com', 100)
|
||||||
|
|
||||||
await search.process()
|
report = await search.process()
|
||||||
|
|
||||||
assert await search.get_hostnames() == []
|
assert await search.get_hostnames() == []
|
||||||
assert await search.get_emails() == set()
|
assert await search.get_emails() == set()
|
||||||
|
assert report == SourceExecutionReport(
|
||||||
|
'failed',
|
||||||
|
'access-denied' if 'Access denied' in payload else 'invalid-response',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize('reason', ['transport-error', 'invalid-response', 'response-limit'])
|
||||||
|
async def test_duckduckgo_reports_bounded_fetch_failures(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
raise ResponseStreamError(reason) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
|
||||||
|
report = await duckduckgosearch.SearchDuckDuckGo('example.com', 100).process()
|
||||||
|
|
||||||
|
assert report == SourceExecutionReport('failed', reason)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_duckduckgo_propagates_cancellation(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await duckduckgosearch.SearchDuckDuckGo('example.com', 100).process()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
('body', 'expected_report'),
|
||||||
|
[
|
||||||
|
({}, None),
|
||||||
|
({'error': 'Rate limit exceeded'}, SourceExecutionReport('rate-limited', 'provider-rate-limit')),
|
||||||
|
({'error': 'Unknown provider failure'}, SourceExecutionReport('failed', 'provider-error')),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_duckduckgo_classifies_valid_empty_and_provider_error_payloads(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
body: dict[str, str],
|
||||||
|
expected_report: SourceExecutionReport | None,
|
||||||
|
) -> None:
|
||||||
|
async def fake_fetch_json(*_args: Any, **_kwargs: Any) -> FetcherResponse:
|
||||||
|
return FetcherResponse(body, 200, {})
|
||||||
|
|
||||||
|
monkeypatch.setattr(duckduckgosearch.AsyncFetcher, 'fetch_json', fake_fetch_json)
|
||||||
|
|
||||||
|
report = await duckduckgosearch.SearchDuckDuckGo('example.com', 100).process()
|
||||||
|
|
||||||
|
assert report == expected_report
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.provider_contract('duckduckgo')
|
pytestmark = pytest.mark.provider_contract('duckduckgo')
|
||||||
|
|||||||
@@ -1,7 +1,88 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from aiohttp import web
|
from aiohttp import ClientSession, web
|
||||||
|
|
||||||
from theHarvester.discovery import censysearch, githubcode
|
from theHarvester.discovery import bravesearch, censysearch, githubcode
|
||||||
|
from theHarvester.lib.configuration import InMemoryCredentialAdapter
|
||||||
|
from theHarvester.lib.source_execution import SourceExecutionReport
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_brave_pagination_preserves_provider_cookies(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
unused_tcp_port: int,
|
||||||
|
) -> None:
|
||||||
|
requests: list[tuple[str | None, str | None]] = []
|
||||||
|
sessions: list[ClientSession] = []
|
||||||
|
original_build_session = bravesearch.AsyncFetcher._build_session
|
||||||
|
|
||||||
|
async def tracked_build_session(*args: object, **kwargs: object) -> ClientSession:
|
||||||
|
session = await original_build_session(*args, **kwargs) # type: ignore[arg-type]
|
||||||
|
sessions.append(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
monkeypatch.setattr(bravesearch.AsyncFetcher, '_build_session', tracked_build_session)
|
||||||
|
|
||||||
|
async def search(request: web.Request) -> web.Response:
|
||||||
|
offset = request.query.get('offset')
|
||||||
|
requests.append((offset, request.cookies.get('provider-session')))
|
||||||
|
if offset == '0':
|
||||||
|
response = web.json_response(
|
||||||
|
{
|
||||||
|
'query': {'more_results_available': True},
|
||||||
|
'web': {
|
||||||
|
'results': [
|
||||||
|
{
|
||||||
|
'title': 'First',
|
||||||
|
'description': 'one.example.com',
|
||||||
|
'url': 'https://one.example.com',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
response.set_cookie('provider-session', 'ready')
|
||||||
|
return response
|
||||||
|
if request.cookies.get('provider-session') != 'ready':
|
||||||
|
return web.json_response({'error': 'missing provider session'}, status=403)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
'query': {'more_results_available': False},
|
||||||
|
'web': {
|
||||||
|
'results': [
|
||||||
|
{
|
||||||
|
'title': 'Second',
|
||||||
|
'description': 'two.example.com',
|
||||||
|
'url': 'https://two.example.com',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_get('/search', search)
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, '127.0.0.1', unused_tcp_port)
|
||||||
|
await site.start()
|
||||||
|
monkeypatch.setattr(bravesearch, 'get_delay', lambda: 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
source = bravesearch.SearchBrave(
|
||||||
|
'example.com',
|
||||||
|
limit=2,
|
||||||
|
credential_adapter=InMemoryCredentialAdapter({'brave': {'key': 'test-token'}}),
|
||||||
|
)
|
||||||
|
source.server = f'http://localhost:{unused_tcp_port}/search'
|
||||||
|
report = await source.process()
|
||||||
|
finally:
|
||||||
|
await runner.cleanup()
|
||||||
|
|
||||||
|
assert requests == [('0', None), ('1', 'ready')]
|
||||||
|
assert await source.get_hostnames() == ['one.example.com', 'two.example.com']
|
||||||
|
assert report == SourceExecutionReport('completed', 'result-limit')
|
||||||
|
assert len(sessions) == 1
|
||||||
|
assert sessions[0].closed is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -961,6 +961,27 @@ async def test_fetch_text_reuses_caller_owned_session_without_closing_it(
|
|||||||
assert len(DummySession.instances) == 1
|
assert len(DummySession.instances) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_json_reuses_caller_owned_session_without_closing_it(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
install_stream_response(monkeypatch, chunks=(b'{"provider":"evidence"}',))
|
||||||
|
session = DummySession(
|
||||||
|
headers={'User-Agent': 'shared'},
|
||||||
|
timeout=core_module.aiohttp.ClientTimeout(total=None),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await AsyncFetcher.fetch_json(
|
||||||
|
'https://provider.example/data',
|
||||||
|
session=session,
|
||||||
|
request_timeout=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.body == {'provider': 'evidence'}
|
||||||
|
assert session.closed is False
|
||||||
|
assert len(DummySession.instances) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fetch_json_accepts_body_at_shared_limit(monkeypatch) -> None:
|
async def test_fetch_json_accepts_body_at_shared_limit(monkeypatch) -> None:
|
||||||
install_stream_response(monkeypatch, chunks=(b'{"a":1}',))
|
install_stream_response(monkeypatch, chunks=(b'{"a":1}',))
|
||||||
|
|||||||
+19
-14
@@ -7,28 +7,33 @@ import pytest
|
|||||||
from theHarvester.discovery.bravesearch import SearchBrave
|
from theHarvester.discovery.bravesearch import SearchBrave
|
||||||
from theHarvester.discovery.constants import MissingKey
|
from theHarvester.discovery.constants import MissingKey
|
||||||
from theHarvester.lib.configuration import InMemoryCredentialAdapter
|
from theHarvester.lib.configuration import InMemoryCredentialAdapter
|
||||||
from theHarvester.lib.core import AsyncFetcher
|
from theHarvester.lib.core import AsyncFetcher, FetcherResponse
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_brave_collects_with_in_memory_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_brave_collects_with_in_memory_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
request_headers: list[dict[str, str]] = []
|
request_headers: list[dict[str, str]] = []
|
||||||
|
|
||||||
async def fetch(*, headers: dict[str, str], **_kwargs: Any) -> dict[str, Any]:
|
async def fetch(*_args: Any, headers: dict[str, str], **_kwargs: Any) -> FetcherResponse:
|
||||||
request_headers.append(headers)
|
request_headers.append(headers)
|
||||||
return {
|
return FetcherResponse(
|
||||||
'web': {
|
{
|
||||||
'results': [
|
'query': {'more_results_available': False},
|
||||||
{
|
'web': {
|
||||||
'title': 'Documentation',
|
'results': [
|
||||||
'description': 'Example documentation',
|
{
|
||||||
'url': 'https://docs.example.com',
|
'title': 'Documentation',
|
||||||
}
|
'description': 'Example documentation',
|
||||||
]
|
'url': 'https://docs.example.com',
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
monkeypatch.setattr(AsyncFetcher, 'fetch', fetch)
|
monkeypatch.setattr(AsyncFetcher, 'fetch_json', fetch)
|
||||||
search = SearchBrave(
|
search = SearchBrave(
|
||||||
'example.com',
|
'example.com',
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import ssl
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from theHarvester.discovery.constants import MissingKey, get_delay
|
import aiohttp
|
||||||
from theHarvester.lib.configuration import CredentialAdapter, FileSystemCredentialAdapter
|
|
||||||
from theHarvester.lib.core import AsyncFetcher
|
|
||||||
from theHarvester.parsers import myparser
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
from theHarvester.discovery.constants import MissingKey, get_delay
|
||||||
|
from theHarvester.discovery.provider_response import provider_http_error
|
||||||
|
from theHarvester.lib.configuration import CredentialAdapter, FileSystemCredentialAdapter
|
||||||
|
from theHarvester.lib.core import AsyncFetcher, ResponseStreamError
|
||||||
|
from theHarvester.lib.source_execution import SourceExecutionReport
|
||||||
|
from theHarvester.parsers import myparser
|
||||||
|
|
||||||
|
|
||||||
class SearchBrave:
|
class SearchBrave:
|
||||||
@@ -34,11 +36,20 @@ class SearchBrave:
|
|||||||
raise MissingKey('Brave Search')
|
raise MissingKey('Brave Search')
|
||||||
self.server = 'https://api.search.brave.com/res/v1/web/search'
|
self.server = 'https://api.search.brave.com/res/v1/web/search'
|
||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.proxy = False
|
self.proxy: bool | str = False
|
||||||
self.rate_limit_delay = 1 # Initial delay for rate limiting
|
|
||||||
|
|
||||||
async def do_search(self):
|
async def do_search(self, session: Any | None = None) -> SourceExecutionReport | None:
|
||||||
headers = {'Accept': 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': self.api_key}
|
headers = {'Accept': 'application/json', 'Accept-Encoding': 'gzip', 'X-Subscription-Token': self.api_key}
|
||||||
|
if session is None:
|
||||||
|
try:
|
||||||
|
async with AsyncFetcher.open_session(
|
||||||
|
headers=headers,
|
||||||
|
proxy=self.proxy,
|
||||||
|
request_timeout=60,
|
||||||
|
) as owned_session:
|
||||||
|
return await self.do_search(owned_session)
|
||||||
|
except ResponseStreamError as error:
|
||||||
|
return SourceExecutionReport('failed', error.reason)
|
||||||
|
|
||||||
# Search queries: exact match and site-specific
|
# Search queries: exact match and site-specific
|
||||||
queries = [f'"{self.word}"', f'site:{self.word}']
|
queries = [f'"{self.word}"', f'site:{self.word}']
|
||||||
@@ -66,77 +77,80 @@ class SearchBrave:
|
|||||||
param_string = '&'.join([f'{k}={quote(str(v))}' for k, v in params.items()])
|
param_string = '&'.join([f'{k}={quote(str(v))}' for k, v in params.items()])
|
||||||
url = f'{self.server}?{param_string}'
|
url = f'{self.server}?{param_string}'
|
||||||
|
|
||||||
resp = await AsyncFetcher.fetch(url=url, headers=headers, proxy=self.proxy, json=True)
|
response = await AsyncFetcher.fetch_json(
|
||||||
|
url,
|
||||||
|
session=session,
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
if failure := provider_http_error(response):
|
||||||
|
return SourceExecutionReport(*failure)
|
||||||
|
resp = response.body
|
||||||
|
|
||||||
# Handle API response
|
# Handle API response
|
||||||
if resp is None:
|
if resp is None:
|
||||||
logger.info('No response received from Brave Search API')
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
break
|
if not isinstance(resp, dict):
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
|
||||||
# Check for API errors (rate limit, quota exceeded, etc.)
|
# Check for API errors (rate limit, quota exceeded, etc.)
|
||||||
if 'error' in resp:
|
if 'error' in resp:
|
||||||
error_msg = resp.get('error', {}).get('message', 'Unknown API error')
|
provider_error = resp['error']
|
||||||
error_code = resp.get('error', {}).get('code', 'unknown')
|
if not isinstance(provider_error, dict):
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
error_message = str(provider_error.get('message', '')).lower()
|
||||||
|
error_code = str(provider_error.get('code', '')).lower()
|
||||||
|
if 'rate limit' in error_message or error_code == 'rate_limit_exceeded':
|
||||||
|
return SourceExecutionReport('rate-limited', 'provider-rate-limit')
|
||||||
|
if 'quota' in error_message or error_code == 'quota_exceeded':
|
||||||
|
return SourceExecutionReport('failed', 'quota-exhausted')
|
||||||
|
return SourceExecutionReport('failed', 'provider-error')
|
||||||
|
|
||||||
if 'rate limit' in error_msg.lower() or error_code == 'rate_limit_exceeded':
|
web = resp.get('web')
|
||||||
logger.info(f'Rate limit exceeded. Increasing delay to {self.rate_limit_delay * 2} seconds')
|
query_data = resp.get('query')
|
||||||
self.rate_limit_delay *= 2
|
if not isinstance(web, dict) or not isinstance(web.get('results'), list):
|
||||||
await asyncio.sleep(self.rate_limit_delay)
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
break
|
if not isinstance(query_data, dict):
|
||||||
elif 'quota' in error_msg.lower() or error_code == 'quota_exceeded':
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
logger.info('Brave Search API quota exceeded')
|
more_results_available = query_data.get('more_results_available')
|
||||||
break
|
if not isinstance(more_results_available, bool):
|
||||||
else:
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
break
|
|
||||||
|
|
||||||
if 'web' in resp and 'results' in resp['web']:
|
results = web['results'][:remaining]
|
||||||
results = resp['web']['results'][:remaining]
|
if any(not isinstance(result, dict) for result in results):
|
||||||
if not results:
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
break
|
if not results:
|
||||||
|
if more_results_available:
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
break
|
||||||
|
|
||||||
# Extract text content from results for parsing (including extra snippets)
|
for result in results:
|
||||||
for result in results:
|
snippets = result.get('extra_snippets', [])
|
||||||
result_text = f'{result.get("title", "")} {result.get("description", "")}'
|
if not isinstance(snippets, list) or any(not isinstance(snippet, str) for snippet in snippets):
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
title = result.get('title', '')
|
||||||
|
description = result.get('description', '')
|
||||||
|
result_url = result.get('url', '')
|
||||||
|
if not all(isinstance(value, str) for value in (title, description, result_url)):
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
result_text = f'{title} {description}'
|
||||||
|
for snippet in snippets:
|
||||||
|
result_text += f' {snippet}'
|
||||||
|
result_text += f' {result_url}'
|
||||||
|
self.totalresults += result_text + '\n'
|
||||||
|
|
||||||
# Add extra snippets if available
|
self.results.extend(results)
|
||||||
if 'extra_snippets' in result:
|
if len(self.results) >= self.limit:
|
||||||
for snippet in result['extra_snippets']:
|
return SourceExecutionReport('completed', 'result-limit')
|
||||||
result_text += f' {snippet}'
|
if not more_results_available:
|
||||||
|
|
||||||
result_text += f' {result.get("url", "")}'
|
|
||||||
self.totalresults += result_text + '\n'
|
|
||||||
|
|
||||||
self.results.extend(results)
|
|
||||||
|
|
||||||
# Stop if we've reached our limit
|
|
||||||
if len(self.results) >= self.limit:
|
|
||||||
break
|
|
||||||
if not resp.get('query', {}).get('more_results_available', False):
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
logger.info('Unexpected response format from Brave Search API')
|
|
||||||
break
|
break
|
||||||
|
|
||||||
await asyncio.sleep(get_delay())
|
await asyncio.sleep(get_delay())
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
error_msg = str(e).lower()
|
|
||||||
|
|
||||||
# Handle specific API-related exceptions
|
|
||||||
if 'rate limit' in error_msg or '429' in error_msg:
|
|
||||||
logger.info(f'Rate limit detected in exception. Increasing delay to {self.rate_limit_delay * 2} seconds')
|
|
||||||
self.rate_limit_delay *= 2
|
|
||||||
await asyncio.sleep(self.rate_limit_delay)
|
|
||||||
elif 'quota' in error_msg or '403' in error_msg:
|
|
||||||
logger.info(f'Quota exceeded or access denied: {e}')
|
|
||||||
break
|
|
||||||
elif 'timeout' in error_msg:
|
|
||||||
logger.info(f'Request timeout occurred: {e}')
|
|
||||||
await asyncio.sleep(get_delay() + 2)
|
|
||||||
else:
|
else:
|
||||||
logger.info(f'An exception has occurred in bravesearch: {e}')
|
return SourceExecutionReport('partial', 'pagination-limit')
|
||||||
await asyncio.sleep(get_delay() + 5)
|
|
||||||
continue
|
except ResponseStreamError as error:
|
||||||
|
return SourceExecutionReport('failed', error.reason)
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_emails(self):
|
async def get_emails(self):
|
||||||
rawres = myparser.Parser(self.totalresults, self.word)
|
rawres = myparser.Parser(self.totalresults, self.word)
|
||||||
@@ -146,6 +160,11 @@ class SearchBrave:
|
|||||||
rawres = myparser.Parser(self.totalresults, self.word)
|
rawres = myparser.Parser(self.totalresults, self.word)
|
||||||
return await rawres.hostnames()
|
return await rawres.hostnames()
|
||||||
|
|
||||||
async def process(self, proxy=False):
|
async def process(self, proxy: bool | str = False) -> SourceExecutionReport | None:
|
||||||
self.proxy = proxy
|
self.proxy = proxy
|
||||||
await self.do_search()
|
try:
|
||||||
|
return await self.do_search()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except aiohttp.ClientError, TimeoutError, OSError, ssl.SSLError:
|
||||||
|
return SourceExecutionReport('failed', 'transport-error')
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import logging
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from theHarvester.discovery.constants import MissingKey
|
from theHarvester.discovery.constants import MissingKey
|
||||||
from theHarvester.lib.core import AsyncFetcher, Core, ResponseStreamError
|
from theHarvester.discovery.provider_response import provider_http_error
|
||||||
|
from theHarvester.lib.core import AsyncFetcher, Core, FetcherResponse, ResponseStreamError
|
||||||
|
from theHarvester.lib.source_execution import SourceExecutionReport
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -42,7 +44,7 @@ class SearchDNSDB:
|
|||||||
return hostname
|
return hostname
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def do_search(self) -> None:
|
async def do_search(self) -> SourceExecutionReport | None:
|
||||||
query = quote(f'*.{self.target_domain}', safe='*.')
|
query = quote(f'*.{self.target_domain}', safe='*.')
|
||||||
url = f'{self.BASE_URL}/{query}?limit=0'
|
url = f'{self.BASE_URL}/{query}?limit=0'
|
||||||
headers = {
|
headers = {
|
||||||
@@ -59,49 +61,56 @@ class SearchDNSDB:
|
|||||||
follow_redirects=False,
|
follow_redirects=False,
|
||||||
request_timeout=120,
|
request_timeout=120,
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 429:
|
if failure := provider_http_error(FetcherResponse(None, response.status, response.headers)):
|
||||||
raise ConnectionError('DNSDB rate limit reached')
|
return SourceExecutionReport(*failure)
|
||||||
if response.status in {401, 403}:
|
|
||||||
raise PermissionError('DNSDB authentication failed')
|
|
||||||
if response.status == 503:
|
|
||||||
raise ConnectionError('DNSDB concurrent connection limit exceeded')
|
|
||||||
if response.status != 200:
|
|
||||||
raise ConnectionError(f'DNSDB returned HTTP {response.status}')
|
|
||||||
|
|
||||||
first_record = True
|
first_record = True
|
||||||
async for line in response:
|
async for line in response:
|
||||||
try:
|
try:
|
||||||
record = json.loads(line)
|
record = json.loads(line)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError, RecursionError:
|
||||||
logger.info('DNSDB returned malformed NDJSON; partial results were preserved.')
|
logger.info('DNSDB returned malformed NDJSON; partial results were preserved.')
|
||||||
return
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
if not isinstance(record, dict):
|
if not isinstance(record, dict):
|
||||||
logger.info('DNSDB returned an invalid stream record; partial results were preserved.')
|
logger.info('DNSDB returned an invalid stream record; partial results were preserved.')
|
||||||
return
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
if first_record:
|
if first_record:
|
||||||
first_record = False
|
first_record = False
|
||||||
if record.get('cond') != 'begin':
|
if record.get('cond') != 'begin':
|
||||||
logger.info('DNSDB stream did not begin correctly; no results were accepted.')
|
logger.info('DNSDB stream did not begin correctly; no results were accepted.')
|
||||||
return
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
condition = record.get('cond')
|
condition = record.get('cond')
|
||||||
if condition in {'succeeded', 'limited', 'failed'}:
|
if condition in {'succeeded', 'limited', 'failed'}:
|
||||||
if condition != 'succeeded':
|
if condition != 'succeeded':
|
||||||
logger.info(f'DNSDB stream ended with {condition}; partial results were preserved.')
|
logger.info(f'DNSDB stream ended with {condition}; partial results were preserved.')
|
||||||
return
|
if condition == 'limited':
|
||||||
|
return SourceExecutionReport('rate-limited', 'provider-limited')
|
||||||
|
if condition == 'failed':
|
||||||
|
return SourceExecutionReport('failed', 'provider-failed')
|
||||||
|
return None
|
||||||
|
if condition is not None:
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
obj = record.get('obj')
|
obj = record.get('obj')
|
||||||
if isinstance(obj, dict) and (hostname := self._hostname(obj.get('rrname'))):
|
if not isinstance(obj, dict):
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
rrname = obj.get('rrname')
|
||||||
|
if not isinstance(rrname, str) or not rrname.strip():
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
if hostname := self._hostname(rrname):
|
||||||
self.totalhosts.add(hostname)
|
self.totalhosts.add(hostname)
|
||||||
|
|
||||||
logger.info('DNSDB stream ended without a terminal condition; partial results were preserved.')
|
logger.info('DNSDB stream ended without a terminal condition; partial results were preserved.')
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
|
||||||
async def get_hostnames(self) -> set[str]:
|
async def get_hostnames(self) -> set[str]:
|
||||||
return self.totalhosts
|
return self.totalhosts
|
||||||
|
|
||||||
async def process(self, proxy: bool | str = False) -> None:
|
async def process(self, proxy: bool | str = False) -> SourceExecutionReport | None:
|
||||||
self.proxy = proxy
|
self.proxy = proxy
|
||||||
try:
|
try:
|
||||||
await self.do_search()
|
return await self.do_search()
|
||||||
except ResponseStreamError as error:
|
except ResponseStreamError as error:
|
||||||
logger.info(f'DNSDB request failed with {type(error).__name__}; partial results were preserved.')
|
logger.info(f'DNSDB request failed with {type(error).__name__}; partial results were preserved.')
|
||||||
|
return SourceExecutionReport('failed', error.reason)
|
||||||
|
|||||||
@@ -1,6 +1,81 @@
|
|||||||
from theHarvester.lib.core import AsyncFetcher, Core
|
from typing import Any
|
||||||
|
|
||||||
|
from theHarvester.discovery.provider_response import provider_http_error
|
||||||
|
from theHarvester.lib.core import AsyncFetcher, Core, ResponseStreamError
|
||||||
|
from theHarvester.lib.source_execution import SourceExecutionReport
|
||||||
from theHarvester.parsers import myparser
|
from theHarvester.parsers import myparser
|
||||||
|
|
||||||
|
_TEXT_FIELDS = (
|
||||||
|
'Abstract',
|
||||||
|
'AbstractSource',
|
||||||
|
'AbstractText',
|
||||||
|
'AbstractURL',
|
||||||
|
'Answer',
|
||||||
|
'AnswerType',
|
||||||
|
'Definition',
|
||||||
|
'DefinitionSource',
|
||||||
|
'DefinitionURL',
|
||||||
|
'Entity',
|
||||||
|
'Heading',
|
||||||
|
'Image',
|
||||||
|
'Redirect',
|
||||||
|
'Type',
|
||||||
|
)
|
||||||
|
_TOPIC_TEXT_FIELDS = ('FirstURL', 'Name', 'Result', 'Text')
|
||||||
|
|
||||||
|
|
||||||
|
def _topic_text(items: object) -> list[str] | None:
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return None
|
||||||
|
text: list[str] = []
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
return None
|
||||||
|
recognized = False
|
||||||
|
for field in _TOPIC_TEXT_FIELDS:
|
||||||
|
if field not in item:
|
||||||
|
continue
|
||||||
|
recognized = True
|
||||||
|
value = item[field]
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
text.append(value)
|
||||||
|
if 'Topics' in item:
|
||||||
|
recognized = True
|
||||||
|
nested = _topic_text(item['Topics'])
|
||||||
|
if nested is None:
|
||||||
|
return None
|
||||||
|
text.extend(nested)
|
||||||
|
if item and not recognized:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _provider_text(body: dict[str, Any]) -> str | None:
|
||||||
|
if not body:
|
||||||
|
return ''
|
||||||
|
recognized = False
|
||||||
|
text: list[str] = []
|
||||||
|
for field in _TEXT_FIELDS:
|
||||||
|
if field not in body:
|
||||||
|
continue
|
||||||
|
recognized = True
|
||||||
|
value = body[field]
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
text.append(value)
|
||||||
|
for field in ('RelatedTopics', 'Results'):
|
||||||
|
if field not in body:
|
||||||
|
continue
|
||||||
|
recognized = True
|
||||||
|
topic_text = _topic_text(body[field])
|
||||||
|
if topic_text is None:
|
||||||
|
return None
|
||||||
|
text.extend(topic_text)
|
||||||
|
if not recognized:
|
||||||
|
return None
|
||||||
|
return '\n'.join(text)
|
||||||
|
|
||||||
|
|
||||||
class SearchDuckDuckGo:
|
class SearchDuckDuckGo:
|
||||||
def __init__(self, word, limit) -> None:
|
def __init__(self, word, limit) -> None:
|
||||||
@@ -15,13 +90,31 @@ class SearchDuckDuckGo:
|
|||||||
self.limit = limit
|
self.limit = limit
|
||||||
self.proxy: bool = False
|
self.proxy: bool = False
|
||||||
|
|
||||||
async def do_search(self) -> None:
|
async def do_search(self) -> SourceExecutionReport | None:
|
||||||
# Query only the provider; URLs in the response are evidence, not crawl targets.
|
# Query only the provider; URLs in the response are evidence, not crawl targets.
|
||||||
url = self.api.replace('x', self.word)
|
url = self.api.replace('x', self.word)
|
||||||
headers = {'User-Agent': Core.get_user_agent()}
|
headers = {'User-Agent': Core.get_user_agent()}
|
||||||
first_resp = await AsyncFetcher.fetch_all([url], headers=headers, proxy=self.proxy)
|
try:
|
||||||
self.results = first_resp[0]
|
response = await AsyncFetcher.fetch_json(url, headers=headers, proxy=self.proxy)
|
||||||
|
except ResponseStreamError as error:
|
||||||
|
return SourceExecutionReport('failed', error.reason)
|
||||||
|
if failure := provider_http_error(response):
|
||||||
|
return SourceExecutionReport(*failure)
|
||||||
|
if not isinstance(response.body, dict):
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
if 'error' in response.body:
|
||||||
|
provider_error = str(response.body['error']).lower()
|
||||||
|
if 'rate limit' in provider_error:
|
||||||
|
return SourceExecutionReport('rate-limited', 'provider-rate-limit')
|
||||||
|
if 'access denied' in provider_error or 'forbidden' in provider_error:
|
||||||
|
return SourceExecutionReport('failed', 'access-denied')
|
||||||
|
return SourceExecutionReport('failed', 'provider-error')
|
||||||
|
provider_text = _provider_text(response.body)
|
||||||
|
if provider_text is None:
|
||||||
|
return SourceExecutionReport('failed', 'invalid-response')
|
||||||
|
self.results = provider_text
|
||||||
self.totalresults += self.results
|
self.totalresults += self.results
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_emails(self):
|
async def get_emails(self):
|
||||||
rawres = myparser.Parser(self.totalresults, self.word)
|
rawres = myparser.Parser(self.totalresults, self.word)
|
||||||
@@ -31,6 +124,6 @@ class SearchDuckDuckGo:
|
|||||||
rawres = myparser.Parser(self.totalresults, self.word)
|
rawres = myparser.Parser(self.totalresults, self.word)
|
||||||
return await rawres.hostnames()
|
return await rawres.hostnames()
|
||||||
|
|
||||||
async def process(self, proxy: bool = False) -> None:
|
async def process(self, proxy: bool = False) -> SourceExecutionReport | None:
|
||||||
self.proxy = proxy
|
self.proxy = proxy
|
||||||
await self.do_search() # Only need to search once since using API.
|
return await self.do_search() # Only need to search once since using API.
|
||||||
|
|||||||
@@ -827,6 +827,7 @@ class AsyncFetcher:
|
|||||||
cls,
|
cls,
|
||||||
url: str,
|
url: str,
|
||||||
*,
|
*,
|
||||||
|
session: aiohttp.ClientSession | None = None,
|
||||||
params: Sized = '',
|
params: Sized = '',
|
||||||
proxy: str | bool | None = '',
|
proxy: str | bool | None = '',
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
@@ -835,6 +836,7 @@ class AsyncFetcher:
|
|||||||
"""Fetch one bounded JSON response without following redirects."""
|
"""Fetch one bounded JSON response without following redirects."""
|
||||||
async with cls._open_get_response(
|
async with cls._open_get_response(
|
||||||
url,
|
url,
|
||||||
|
session=session,
|
||||||
params=params,
|
params=params,
|
||||||
proxy=proxy,
|
proxy=proxy,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
|
|||||||
Reference in New Issue
Block a user