diff --git a/CHANGELOG.md b/CHANGELOG.md index 30ac0195..b3841b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added root contributor and security policies, structured issue forms, repository agent guidance, discovery terminology, and an operator-focused documentation wiki ([d090a29a](https://github.com/laramies/theHarvester/commit/d090a29a), [7c491ef5](https://github.com/laramies/theHarvester/commit/7c491ef5), [8b9d420b](https://github.com/laramies/theHarvester/commit/8b9d420b)). ### Changed +- Replaced Shodan's synchronous Python SDK with cancellable async Host API requests that honor configured proxies, query every unique resolved IPv4, paginate target-bound hostname and TLS-certificate searches without an adapter-specific result cap, retain successful partial results, and add no source-local deadline. Shodan now stores one canonical `shodan-host` result per IP with every normalized TCP or UDP service and scoped certificate CN/SAN metadata in native JSONL, SQLite, API, and HarvestView details instead of an escaped JSON value. - Removed the transport-wide delay before reading ready HTTP responses, bounded Wayback Archive to 30 seconds and Common Crawl to 120 seconds, kept both sources within the requested result limit, and made long-source progress visible in verbose mode. Common Crawl now requests one 50-record page at a time instead of bursting page batches. - Made Baidu, crt.sh, HackerTarget, Have I Been Pwned, Mojeek, OTX, and Robtex report blocked, malformed, or transport failures truthfully. Also fixed HackerTarget CSV parsing and Robtex AAAA results. - Hardened BufferOver, ProjectDiscovery, DNSDumpster, ONYPHE, and URLScan parsing and result attribution, including scoped typed results and bounded URLScan pagination. diff --git a/README.md b/README.md index 06714d24..7beb060e 100644 --- a/README.md +++ b/README.md @@ -247,6 +247,7 @@ On first use, theHarvester creates default configuration files under `~/.theHarv - `api-keys.yaml` stores provider credentials. - `proxies.yaml` configures HTTP and SOCKS5 proxies used with `-p`. +- The `shodan` source and `-s` / `--shodan` enrichment call Shodan's Host REST API without the Python SDK. When `-p` is enabled, both send those requests through `proxies.yaml`. Never commit populated configuration files, API keys, account details, or provider responses. @@ -273,7 +274,7 @@ The JSON report is a single object. Host entries remain plain hostnames or `host | --- | --- | --- | | `cmd` | Always | Command-line arguments used for the run. | | `hosts` | Always | Discovered hosts; an empty array when none are found. | -| `shodan` | Always | Shodan enrichment rows; an empty array when Shodan is not used. | +| `shodan` | Always | Shodan host objects with canonical IP `value` and structured `details`; an empty array when Shodan is not used. | | `ips`, `emails`, `vhosts`, `asns`, `prefixes` | When non-empty | Network and contact findings. RouteViews prefixes are external routing relationships, not claimed target scope. | | `urls` | When non-empty | Discovered URLs from every URL-producing source or action. | | `people`, `twitter_people`, `linkedin_people` | When non-empty | People and profile findings. | @@ -288,7 +289,9 @@ The JSONL report is finalized after the selected one-shot actions finish. The fi {"sources":[],"type":"hostname","value":"api.example.com"} ``` -JSONL is easy to stream one record at a time. The summary preserves the evidence status, source and action outcomes, and screenshot artifact metadata. Finding lines carry `sources` and, when applicable, `actions`; they inherit their run ID and target from the preceding summary. Hostnames, IP addresses, and URLs use the same `hostname`, `ip`, and `url` result kinds in JSONL, SQLite, the API, and HarvestView. Provenance identifies which source or action produced each finding. Structured result types, including recursive DNS records plus `person`, `infostealer`, `shodan`, and `takeover`, store a JSON object inside the string `value`. Parse those values a second time with `fromjson`. +JSONL is easy to stream one record at a time. The summary preserves the evidence status, source and action outcomes, and screenshot artifact metadata. Finding lines carry `sources` and, when applicable, `actions`; they inherit their run ID and target from the preceding summary. Hostnames, IP addresses, and URLs use the same `hostname`, `ip`, and `url` result kinds in JSONL, SQLite, the API, and HarvestView. Provenance identifies which source or action produced each finding. Recursive DNS records plus `person`, `infostealer`, and `takeover` store a JSON object inside the string `value`; parse those values a second time with `fromjson`. + +Shodan host findings instead use the canonical IP as `value` and place normalized host and per-service evidence in a native `details` object. Shodan discovery paginates both hostname and TLS-certificate searches for the target domain without an adapter-specific result cap, merges duplicate services by IP, and rejects names outside the requested domain. Host metadata appears once, while each service retains its port, TCP or UDP transport, product, version, observation time, CPEs, and available HTTP or TLS summary, including scoped certificate CNs and SANs. Raw banners, response bodies, certificate chains, and Shodan crawler metadata are not retained. Virtual-host observations do not use that string encoding. Each confirmed name remains one `hostname` finding with `actions: ["vhost"]` and a native `observations` array. Several endpoint observations can enrich the same hostname without creating another result kind or count. @@ -302,6 +305,12 @@ Parse recursive DNS findings as JSON objects: jq -c 'select(.type == "dns-recursive-finding") | .value | fromjson' report.jsonl ``` +List Shodan services by host: + +```bash +jq -c 'select(.type == "shodan-host") | {ip: .value, services: .details.services}' report.jsonl +``` + List the endpoint observations for each confirmed virtual host: ```bash diff --git a/pyproject.toml b/pyproject.toml index 8f7892b3..198fb3a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,6 @@ dependencies = [ "python-dateutil==2.9.0.post0", "httpx==0.28.1", "retrying==1.4.2", - "shodan==1.31.0", "sqlalchemy==2.0.51", "ujson==5.13.0", "uvicorn==0.52.1", diff --git a/tests/discovery/test_shodan_engine.py b/tests/discovery/test_shodan_engine.py index 6659b203..5178b29b 100644 --- a/tests/discovery/test_shodan_engine.py +++ b/tests/discovery/test_shodan_engine.py @@ -1,35 +1,172 @@ +import asyncio import logging import socket import sys from collections import OrderedDict from datetime import UTC +from types import SimpleNamespace +from uuid import UUID import pytest +def patch_resolution(monkeypatch, module, addresses=('203.0.113.10',)): + requested_targets = [] + + async def resolve_ip_addresses(target, *, family=socket.AF_UNSPEC): + requested_targets.append((target, family)) + return tuple(addresses) + + monkeypatch.setattr(module, 'resolve_ip_addresses', resolve_ip_addresses, raising=True) + return requested_targets + + +@pytest.fixture(autouse=True) +def disable_shodan_pacing(monkeypatch): + from theHarvester.discovery import shodansearch + + monkeypatch.setattr(shodansearch.SearchShodan, 'REQUEST_INTERVAL_SECONDS', 0.0) + + class TestShodanEngine: @pytest.mark.asyncio - async def test_shodan_retains_sourced_asn_organization_attribution(self, monkeypatch): - from theHarvester.discovery import shodansearch + async def test_shared_ipv4_resolution_returns_every_unique_address_and_closes(self, monkeypatch): + from theHarvester.lib import hostchecker - class SuccessfulShodan: - def host(self, _ip): - return { + closed = False + + class Resolver: + async def getaddrinfo(self, target, *, family): + assert target == 'example.test' + assert family == socket.AF_INET + return SimpleNamespace( + nodes=[ + SimpleNamespace(addr=(b'203.0.113.11', 0)), + SimpleNamespace(addr=('invalid', 0)), + SimpleNamespace(addr=('203.0.113.10', 0)), + SimpleNamespace(addr=('203.0.113.11', 0)), + ] + ) + + async def close(self): + nonlocal closed + closed = True + + monkeypatch.setattr(hostchecker.aiodns, 'DNSResolver', Resolver) + + assert await hostchecker.resolve_ip_addresses('example.test', family=socket.AF_INET) == ( + '203.0.113.10', + '203.0.113.11', + ) + assert closed is True + + @pytest.mark.asyncio + async def test_shodan_calls_direct_host_api_through_proxy_and_retains_attribution(self, monkeypatch): + from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse + + async def fetch_json(url, *, params, proxy, request_timeout): + assert url == 'https://api.shodan.io/shodan/host/192.0.2.10' + assert 'test-key' not in url + assert params == {'key': 'test-key'} + assert proxy is True + assert request_timeout is None + return FetcherResponse( + body={ 'asn': 'AS64496', - 'org': 'Example Transit', + 'domains': ['Example.TEST.'], + 'hostnames': ['API.Example.TEST.'], + 'ip_str': '192.0.2.10', 'isp': 'Example ISP Label', - 'data': [{'ip_str': '198.51.100.20'}], - } + 'org': 'Example Transit', + 'data': [ + { + 'port': 53, + 'transport': 'udp', + 'product': 'dnsmasq', + 'version': '2.90', + 'timestamp': '2026-08-14T11:58:00Z', + 'cpe': ['cpe:/a:thekelleys:dnsmasq:2.90'], + 'data': 'raw provider banner must not be retained', + }, + { + 'port': 443, + 'transport': 'tcp', + 'product': 'nginx', + 'version': '1.24.0', + 'timestamp': '2026-08-14T12:01:00Z', + 'http': { + 'components': {'nginx': {}, 'Python': {}}, + 'server': 'nginx', + 'title': 'Example', + 'html': 'raw response body must not be retained', + }, + 'ssl': { + 'jarm': 'example-jarm', + 'cert': { + 'subject': {'CN': 'api.example.test'}, + 'issuer': {'CN': 'Example CA'}, + 'expires': '2027-08-14T00:00:00Z', + 'fingerprint': {'sha256': '0123456789abcdef'}, + 'extensions': [ + { + 'name': 'subjectAltName', + 'data': r'0\x82\x10api.example.test\x82\x10www.example.test', + } + ], + }, + }, + }, + ], + }, + status=200, + headers={}, + ) monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') - monkeypatch.setattr(shodansearch, 'Shodan', lambda _key: SuccessfulShodan()) + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) search = shodansearch.SearchShodan() - await search.search_ip('192.0.2.10') + result = await search.search_ip('192.0.2.10', proxy=True) - attributions = await search.get_asn_attributions() - assert len(attributions) == 1 - attribution = next(iter(attributions)) + assert result['192.0.2.10'] == { + 'asn': 'AS64496', + 'domains': ['example.test'], + 'hostnames': ['api.example.test'], + 'isp': 'Example ISP Label', + 'organization': 'Example Transit', + 'services': [ + { + 'cpes': ['cpe:/a:thekelleys:dnsmasq:2.90'], + 'observed_at': '2026-08-14T11:58:00Z', + 'port': 53, + 'product': 'dnsmasq', + 'transport': 'udp', + 'version': '2.90', + }, + { + 'http': { + 'components': ['Python', 'nginx'], + 'server': 'nginx', + 'title': 'Example', + }, + 'observed_at': '2026-08-14T12:01:00Z', + 'port': 443, + 'product': 'nginx', + 'tls': { + 'expires_at': '2027-08-14T00:00:00Z', + 'issuer_cn': 'Example CA', + 'jarm': 'example-jarm', + 'sha256': '0123456789abcdef', + 'subject_alt_names': ['www.example.test'], + 'subject_cn': 'api.example.test', + }, + 'transport': 'tcp', + 'version': '1.24.0', + }, + ], + } + attribution = next(iter(await search.get_asn_attributions())) assert attribution.producer_kind == 'action' assert attribution.producer == 'shodan' assert attribution.asn == 'AS64496' @@ -39,15 +176,15 @@ class TestShodanEngine: assert attribution.collected_at.tzinfo is UTC @pytest.mark.asyncio - async def test_shodan_provider_failure_returns_attributed_empty_evidence(self, monkeypatch, caplog): + async def test_shodan_maps_404_to_a_successful_zero_yield(self, monkeypatch, caplog): from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse - class FailingShodan: - def host(self, _ip): - raise shodansearch.exception.APIError('No information available for that IP.') + async def fetch_json(*_args, **_kwargs): + return FetcherResponse(body=None, status=404, headers={}) monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') - monkeypatch.setattr(shodansearch, 'Shodan', lambda _key: FailingShodan()) + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) caplog.set_level(logging.INFO, logger=shodansearch.__name__) search = shodansearch.SearchShodan() @@ -58,67 +195,385 @@ class TestShodanEngine: assert '203.0.113.1: Not in Shodan' in caplog.text @pytest.mark.asyncio - async def test_shodan_api_failure_exposes_only_its_error_type(self, monkeypatch, caplog): + @pytest.mark.parametrize('status', [401, 403, 429, 500]) + async def test_shodan_classifies_http_failures_without_provider_payload(self, monkeypatch, caplog, status): from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse - class FailingShodan: - def host(self, _ip): - raise shodansearch.exception.APIError('provider-secret-payload') + async def fetch_json(*_args, **_kwargs): + return FetcherResponse(body=None, status=status, headers={'x-provider-secret': 'secret'}) monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') - monkeypatch.setattr(shodansearch, 'Shodan', lambda _key: FailingShodan()) + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) caplog.set_level(logging.INFO, logger=shodansearch.__name__) search = shodansearch.SearchShodan() result = await search.search_ip('203.0.113.1') assert result == OrderedDict({'203.0.113.1': 'Shodan request failed'}) - assert search.error_type == 'APIError' - assert 'provider-secret-payload' not in caplog.text + assert search.error_type == f'HTTP{status}Error' + assert 'secret' not in caplog.text @pytest.mark.asyncio - async def test_shodan_unexpected_failure_exposes_only_its_error_type(self, monkeypatch): + async def test_shodan_classifies_bounded_transport_failure(self, monkeypatch): from theHarvester.discovery import shodansearch + from theHarvester.lib.core import ResponseStreamError - class FailingShodan: - def host(self, _ip): - raise RuntimeError('provider-secret-payload') + async def fetch_json(*_args, **_kwargs): + raise ResponseStreamError('response-limit') monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') - monkeypatch.setattr(shodansearch, 'Shodan', lambda _key: FailingShodan()) + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) search = shodansearch.SearchShodan() result = await search.search_ip('203.0.113.1') assert result == OrderedDict({'203.0.113.1': 'Shodan request failed'}) - assert search.error_type == 'RuntimeError' + assert search.error_type == 'ResponseLimitError' @pytest.mark.asyncio - async def test_shodan_engine_processes_without_work_item_error_and_yields_hostnames(self, monkeypatch, capsys): - # Import inside the test so monkeypatching affects the already-imported module namespace. + async def test_shodan_discovery_queries_every_resolved_ipv4_and_keeps_partial_results(self, monkeypatch): + from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse + + queried_urls = [] + + async def fetch_json(url, **_kwargs): + if url.endswith('/search'): + return FetcherResponse(body={'matches': [], 'total': 0}, status=200, headers={}) + queried_urls.append(url) + ip = url.rsplit('/', 1)[-1] + if ip == '203.0.113.10': + return FetcherResponse(body=None, status=500, headers={}) + return FetcherResponse( + body={ + 'data': [{'ip_str': ip, 'port': 443, 'transport': 'tcp'}], + 'hostnames': ['CDN.Example.TEST.', 'outside.test'], + }, + status=200, + headers={}, + ) + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) + targets = patch_resolution(monkeypatch, shodansearch, ('203.0.113.10', '203.0.113.11')) + + search = shodansearch.SearchShodan('WWW.Example.TEST.') + await search.process(proxy=True) + + assert targets == [('www.example.test', socket.AF_INET)] + assert queried_urls == [ + 'https://api.shodan.io/shodan/host/203.0.113.10', + 'https://api.shodan.io/shodan/host/203.0.113.11', + ] + assert await search.get_hostnames() == {'cdn.example.test'} + assert search.execution_status == 'partial' + assert search.stop_reason == 'provider-errors' + + @pytest.mark.asyncio + async def test_shodan_discovery_paginates_hostname_and_tls_searches_with_scoped_certificate_names(self, monkeypatch): + from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse + + search_calls = [] + + def banner( + ip, + port, + *, + hostnames=(), + subject_cn=None, + subject_alt_name='', + ): + cert = { + 'extensions': [ + {'name': 'subjectAltName', 'data': subject_alt_name}, + {'name': 'keyUsage', 'data': 'Digital Signature'}, + ] + } + if subject_cn is not None: + cert['subject'] = {'CN': subject_cn} + return { + 'ip_str': ip, + 'hostnames': list(hostnames), + 'port': port, + 'transport': 'tcp', + 'ssl': {'cert': cert}, + } + + hostname_pages = { + 1: { + 'total': 2, + 'matches': [ + banner( + '198.51.100.20', + 443, + hostnames=('API.Example.TEST.',), + subject_cn='*.example.test', + subject_alt_name=(r'0\x82\x10api.example.test\x82\x12outside.invalid\x82\x13vpn.example.test'), + ) + ], + }, + 2: { + 'total': 2, + 'matches': [banner('198.51.100.20', 8443, hostnames=('api.example.test',))], + }, + } + ssl_page = { + 'total': 3, + 'matches': [ + banner( + '198.51.100.20', + 443, + hostnames=('api.example.test',), + subject_cn='*.example.test', + subject_alt_name=r'0\x82\x10api.example.test\x82\x13vpn.example.test', + ), + banner( + '198.51.100.21', + 443, + subject_cn='cert.example.test', + subject_alt_name=r'0\x82\x13cert.example.test\x82\x15outside.invalid', + ), + banner( + '198.51.100.22', + 443, + hostnames=('outside.invalid',), + subject_cn='outside.invalid', + subject_alt_name=r'0\x82\x15outside.invalid', + ), + ], + } + + async def fetch_json(url, *, params, proxy, request_timeout): + assert proxy is True + assert request_timeout is None + assert params['key'] == 'test-key' + if url.endswith('/search'): + search_calls.append(dict(params)) + query = params['query'] + if query == 'hostname:example.test': + return FetcherResponse(body=hostname_pages[params['page']], status=200, headers={}) + assert query == 'ssl:example.test' + assert params['page'] == 1 + return FetcherResponse(body=ssl_page, status=200, headers={}) + assert url == 'https://api.shodan.io/shodan/host/203.0.113.10' + return FetcherResponse( + body={'data': [{'port': 80, 'transport': 'tcp'}], 'hostnames': ['www.example.test']}, + status=200, + headers={}, + ) + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) + patch_resolution(monkeypatch, shodansearch) + + search = shodansearch.SearchShodan('example.test') + await search.process(proxy=True) + + assert [(call['query'], call['page']) for call in search_calls] == [ + ('hostname:example.test', 1), + ('hostname:example.test', 2), + ('ssl:example.test', 1), + ] + assert all(call['minify'] == 'false' and call['fields'] == search.SEARCH_FIELDS for call in search_calls) + assert await search.get_hostnames() == { + 'api.example.test', + 'cert.example.test', + 'vpn.example.test', + 'www.example.test', + } + hosts = {host.ip: host.to_details() for host in await search.get_shodan_hosts()} + assert set(hosts) == {'198.51.100.20', '198.51.100.21', '203.0.113.10'} + assert [service['port'] for service in hosts['198.51.100.20']['services']] == [443, 8443] + assert hosts['198.51.100.20']['services'][0]['tls'] == { + 'subject_alt_names': ['api.example.test', 'vpn.example.test'], + 'subject_cn': '*.example.test', + } + assert hosts['198.51.100.21']['services'][0]['tls'] == {'subject_cn': 'cert.example.test'} + assert search.execution_status == 'completed' + assert search.stop_reason is None + + @pytest.mark.asyncio + async def test_shodan_discovery_counts_service_only_evidence_as_a_result(self, monkeypatch): + from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse + + async def fetch_json(url, **_kwargs): + if url.endswith('/search'): + return FetcherResponse(body={'matches': [], 'total': 0}, status=200, headers={}) + return FetcherResponse( + body={'data': [{'port': 53, 'transport': 'udp'}], 'hostnames': []}, + status=200, + headers={}, + ) + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) + patch_resolution(monkeypatch, shodansearch) + + search = shodansearch.SearchShodan('example.test') + await search.process() + + assert search.execution_status == 'completed' + assert search.stop_reason is None + assert [host.ip for host in await search.get_shodan_hosts()] == ['203.0.113.10'] + + @pytest.mark.asyncio + async def test_shodan_discovery_retains_valid_services_and_reports_malformed_provider_data(self, monkeypatch): + from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse + + async def fetch_json(*_args, **_kwargs): + return FetcherResponse( + body={ + 'data': [ + {'port': 53, 'transport': 'udp', 'product': {'raw': 'provider object'}}, + {'transport': 'tcp'}, + ], + 'hostnames': [], + }, + status=200, + headers={}, + ) + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) + patch_resolution(monkeypatch, shodansearch) + + search = shodansearch.SearchShodan('example.test') + await search.process() + + assert search.execution_status == 'partial' + assert search.stop_reason == 'provider-error' + assert search.error_type == 'InvalidResponseError' + assert (await search.get_shodan_hosts())[0].to_details() == {'services': [{'port': 53, 'transport': 'udp'}]} + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ('status', 'expected_reason'), + [(401, 'access-denied'), (403, 'access-denied'), (429, 'rate-limited')], + ) + async def test_shodan_discovery_reports_specific_provider_stop_reason(self, monkeypatch, status, expected_reason): + from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse + + async def fetch_json(*_args, **_kwargs): + return FetcherResponse(body=None, status=status, headers={}) + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) + patch_resolution(monkeypatch, shodansearch) + + search = shodansearch.SearchShodan('example.test') + await search.process() + + assert search.execution_status == 'failed' + assert search.stop_reason == expected_reason + + def test_shodan_discovery_requires_a_configured_key(self, monkeypatch): + from theHarvester.discovery import shodansearch + from theHarvester.discovery.constants import MissingKey + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: None) + + with pytest.raises(MissingKey): + shodansearch.SearchShodan('example.test') + + def test_shodan_discovery_rejects_query_filter_injection(self, monkeypatch): + from theHarvester.discovery import shodansearch + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') + + with pytest.raises(ValueError, match='must be a hostname'): + shodansearch.SearchShodan('example.test ssl:true') + + @pytest.mark.asyncio + async def test_shodan_discovery_reports_dns_failure_after_searching_provider(self, monkeypatch): + from theHarvester.discovery import shodansearch + from theHarvester.lib.core import FetcherResponse + + provider_queries = [] + + async def fail_resolution(_target, *, family): + assert family == socket.AF_INET + raise shodansearch.aiodns.error.DNSError(shodansearch.aiodns.error.ARES_ENOTFOUND, 'not found') + + async def fetch_json(url, *, params, **_kwargs): + assert url.endswith('/search') + provider_queries.append(params['query']) + return FetcherResponse(body={'matches': [], 'total': 0}, status=200, headers={}) + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') + monkeypatch.setattr(shodansearch, 'resolve_ip_addresses', fail_resolution) + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) + + search = shodansearch.SearchShodan('example.test') + await search.process() + + assert not await search.get_hostnames() + assert provider_queries == ['hostname:example.test', 'ssl:example.test'] + assert search.execution_status == 'failed' + assert search.stop_reason == 'dns-resolution-failed' + + @pytest.mark.asyncio + async def test_shodan_direct_request_cancellation_propagates(self, monkeypatch): + from theHarvester.discovery import shodansearch + + entered = asyncio.Event() + release = asyncio.Event() + + async def fetch_json(*_args, **_kwargs): + entered.set() + await release.wait() + raise AssertionError('cancelled request resumed') + + monkeypatch.setattr(shodansearch.Core, 'shodan_key', lambda: 'test-key') + monkeypatch.setattr(shodansearch.AsyncFetcher, 'fetch_json', fetch_json) + patch_resolution(monkeypatch, shodansearch) + + task = asyncio.create_task(shodansearch.SearchShodan('example.test').process()) + await entered.wait() + task.cancel('operator-stop') + with pytest.raises(asyncio.CancelledError, match='operator-stop'): + await task + release.set() + + @pytest.mark.asyncio + async def test_shodan_engine_processes_and_persists_sourced_hostnames(self, monkeypatch, capsys, tmp_path): import theHarvester.__main__ as main_module + from theHarvester.lib.completed_result import ResultObservation, SourceExecution + from theHarvester.lib.database import ResultStore + from theHarvester.lib.shodan_evidence import ShodanHostObservation - # Make DNS resolution deterministic and offline. - monkeypatch.setattr(socket, 'gethostbyname', lambda _domain: '1.2.3.4', raising=True) + database = tmp_path / 'stash.sqlite' + monkeypatch.setattr(main_module, 'ResultStore', lambda: ResultStore(database), raising=True) - # Avoid filesystem/sqlite side effects. - class DummyResultStore: - async def initialize(self) -> None: - return None - - async def record_observations(self, domain, all, res_type, source) -> None: - return None - - monkeypatch.setattr(main_module, 'ResultStore', DummyResultStore, raising=True) - - # Stub Shodan search to avoid network and API key requirements. class DummySearchShodan: - async def search_ip(self, ip): - return OrderedDict({ip: {'hostnames': ['a.example.com', 'b.example.com']}}) + def __init__(self, domain): + assert domain == 'example.com' + self.shodan_host = ShodanHostObservation.from_record( + '192.0.2.10', + { + 'organization': 'Example Transit', + 'hostnames': ['a.example.com', 'b.example.com'], + 'services': [ + {'port': 53, 'transport': 'udp'}, + {'port': 443, 'transport': 'tcp', 'product': 'nginx'}, + ], + }, + ) + + async def process(self, proxy=False): + return None + + async def get_hostnames(self): + return {'a.example.com', 'b.example.com'} + + async def get_shodan_hosts(self): + return (self.shodan_host,) monkeypatch.setattr(main_module.shodansearch, 'SearchShodan', DummySearchShodan, raising=True) - - # Run the CLI path that uses the engine queue/worker (`-b shodan`). monkeypatch.setattr(sys, 'argv', ['theHarvester', '-d', 'example.com', '-b', 'shodan'], raising=True) with pytest.raises(SystemExit) as excinfo: @@ -127,54 +582,42 @@ class TestShodanEngine: out = capsys.readouterr().out assert 'An error occurred while processing a "work item"' not in out - output_tokens = set(out.split()) - assert {'a.example.com', 'b.example.com'} <= output_tokens + assert {'a.example.com', 'b.example.com'} <= set(out.split()) + assert '"type": "shodan-host"' in out + assert '"value": "192.0.2.10"' in out + assert '"transport": "udp"' in out + assert '"transport": "tcp"' in out + + store = ResultStore(database) + try: + runs = await store.list_runs() + completed = await store.load_run(UUID(str(runs[0]['run_id']))) + assert len(completed.source_executions) == 1 + execution = completed.source_executions[0] + assert execution == SourceExecution('shodan', 'completed', execution.duration_ms, 3) + assert completed.observations == ( + ResultObservation('shodan', 'hostname', 'a.example.com'), + ResultObservation('shodan', 'hostname', 'b.example.com'), + ResultObservation('shodan', 'shodan-host', '192.0.2.10'), + ) + assert completed.shodan_hosts == (DummySearchShodan('example.com').shodan_host,) + finally: + await store.dispose() @pytest.mark.asyncio - async def test_shodan_internetdb_ignores_non_string_resolved_addresses(self, monkeypatch): + async def test_shodan_internetdb_uses_shared_resolution_and_normalizes_scope(self, monkeypatch): from theHarvester.discovery import shodan_internetdb - monkeypatch.setattr( - shodan_internetdb.socket, - 'getaddrinfo', - lambda *_args: [ - (socket.AF_INET, socket.SOCK_STREAM, 0, '', ('203.0.113.1', 0)), - (socket.AF_INET, socket.SOCK_STREAM, 0, '', (12345, 0)), - ], - raising=True, - ) + targets = patch_resolution(monkeypatch, shodan_internetdb, ('203.0.113.1',)) async def fake_fetch_all(urls, json=False, proxy=False): assert urls == ['https://internetdb.shodan.io/203.0.113.1'] - return [{'ip': '203.0.113.1', 'hostnames': ['www.example.com']}] - - monkeypatch.setattr(shodan_internetdb.AsyncFetcher, 'fetch_all', fake_fetch_all, raising=True) - - search = shodan_internetdb.SearchShodanInternetDB('example.com') - await search.process() - - assert await search.get_hostnames() == {'www.example.com'} - assert await search.get_ips() == {'203.0.113.1'} - - @pytest.mark.asyncio - async def test_shodan_internetdb_normalizes_scoped_provider_evidence(self, monkeypatch): - from theHarvester.discovery import shodan_internetdb - - monkeypatch.setattr( - shodan_internetdb.socket, - 'getaddrinfo', - lambda *_args: [(socket.AF_INET, socket.SOCK_STREAM, 0, '', ('203.0.113.1', 0))], - raising=True, - ) - - async def fake_fetch_all(_urls, json=False, proxy=False): return [ { 'ip': '203.0.113.1', 'hostnames': ['API.Example.TEST.', 'www.notexample.test', None], 'ports': [443], - }, - {'ip': '198.51.100.2', 'ports': [80]}, + } ] monkeypatch.setattr(shodan_internetdb.AsyncFetcher, 'fetch_all', fake_fetch_all, raising=True) @@ -182,6 +625,7 @@ class TestShodanEngine: search = shodan_internetdb.SearchShodanInternetDB(' Example.TEST. ') await search.process() + assert targets == [('example.test', socket.AF_UNSPEC)] assert await search.get_hostnames() == {'api.example.test'} assert await search.get_ips() == {'203.0.113.1'} @@ -189,12 +633,7 @@ class TestShodanEngine: async def test_shodan_internetdb_rejects_evidence_for_an_unrequested_ip(self, monkeypatch): from theHarvester.discovery import shodan_internetdb - monkeypatch.setattr( - shodan_internetdb.socket, - 'getaddrinfo', - lambda *_args: [(socket.AF_INET, socket.SOCK_STREAM, 0, '', ('203.0.113.1', 0))], - raising=True, - ) + patch_resolution(monkeypatch, shodan_internetdb, ('203.0.113.1',)) async def fake_fetch_all(_urls, json=False, proxy=False): return [ @@ -220,38 +659,17 @@ class TestShodanEngine: assert not await search.get_tags() assert not await search.get_cpes() - @pytest.mark.asyncio - async def test_shodan_internetdb_retains_a_matching_ip_with_empty_details(self, monkeypatch): - from theHarvester.discovery import shodan_internetdb - - monkeypatch.setattr( - shodan_internetdb.socket, - 'getaddrinfo', - lambda *_args: [(socket.AF_INET, socket.SOCK_STREAM, 0, '', ('203.0.113.1', 0))], - raising=True, - ) - - async def fake_fetch_all(_urls, json=False, proxy=False): - return [{'ip': '203.0.113.1', 'hostnames': [], 'ports': [], 'vulns': [], 'tags': [], 'cpes': []}] - - monkeypatch.setattr(shodan_internetdb.AsyncFetcher, 'fetch_all', fake_fetch_all, raising=True) - - search = shodan_internetdb.SearchShodanInternetDB('example.test') - await search.process() - - assert await search.get_ips() == {'203.0.113.1'} - @pytest.mark.asyncio async def test_shodan_internetdb_resolution_failure_skips_provider_request(self, monkeypatch): from theHarvester.discovery import shodan_internetdb - def fail_resolution(*_args): - raise socket.gaierror + async def fail_resolution(_target): + raise shodan_internetdb.aiodns.error.DNSError(shodan_internetdb.aiodns.error.ARES_ENOTFOUND, 'not found') async def fail_fetch(*_args, **_kwargs): raise AssertionError('provider request must not run') - monkeypatch.setattr(shodan_internetdb.socket, 'getaddrinfo', fail_resolution, raising=True) + monkeypatch.setattr(shodan_internetdb, 'resolve_ip_addresses', fail_resolution) monkeypatch.setattr(shodan_internetdb.AsyncFetcher, 'fetch_all', fail_fetch, raising=True) search = shodan_internetdb.SearchShodanInternetDB('example.test') @@ -264,12 +682,7 @@ class TestShodanEngine: async def test_shodan_internetdb_provider_failure_completes_without_evidence(self, monkeypatch, caplog): from theHarvester.discovery import shodan_internetdb - monkeypatch.setattr( - shodan_internetdb.socket, - 'getaddrinfo', - lambda *_args: [(socket.AF_INET, socket.SOCK_STREAM, 0, '', ('203.0.113.1', 0))], - raising=True, - ) + patch_resolution(monkeypatch, shodan_internetdb, ('203.0.113.1',)) async def fail_fetch(*_args, **_kwargs): raise RuntimeError('provider-secret-payload') diff --git a/tests/lib/test_api_v1.py b/tests/lib/test_api_v1.py index ef3e64f2..562e1d0b 100644 --- a/tests/lib/test_api_v1.py +++ b/tests/lib/test_api_v1.py @@ -436,6 +436,7 @@ def test_openapi_explains_scope_and_execution_controls(tmp_path, monkeypatch) -> 'actions', 'scope', 'observations', + 'details', } assert 'VirtualHostResult' not in schema['components']['schemas'] assert export_content['application/x-ndjson']['schema']['description'] == ( @@ -1198,6 +1199,111 @@ def test_api_database_upload_preserves_grouped_virtual_host_observations(tmp_pat assert json.loads(exported.text.splitlines()[1]) == detail.json()['results'][0] +def test_api_import_preserves_structured_shodan_host_details(tmp_path, monkeypatch) -> None: + from theHarvester.lib.api import api + + details = { + 'organization': 'Example Transit', + 'services': [ + {'port': 53, 'transport': 'udp'}, + { + 'port': 443, + 'transport': 'tcp', + 'product': 'nginx', + 'tls': { + 'subject_cn': '*.example.test', + 'subject_alt_names': ['api.example.test'], + 'sha256': '0123456789abcdef', + }, + }, + ], + } + payload = _jsonl_result( + finding_type='shodan-host', + value='192.0.2.10', + finding_fields={'actions': ['shodan'], 'details': details}, + summary_fields={ + 'action_executions': [ + { + 'action': 'shodan', + 'status': 'completed', + 'duration_ms': 1, + 'result_count': 1, + 'error_type': None, + 'stop_reason': None, + } + ] + }, + ) + monkeypatch.setenv('THEHARVESTER_API_KEY', 'test-key') + monkeypatch.setenv('THEHARVESTER_RUN_DB', str(tmp_path / 'runs.sqlite')) + monkeypatch.setenv('THEHARVESTER_RUN_WORKER', 'disabled') + + with TestClient(api.app) as client: + imported = client.post( + '/api/v1/runs/import', + params={'filename': 'shodan.jsonl'}, + headers={'X-API-Key': 'test-key'}, + content=payload, + ) + assert imported.status_code == 201, imported.text + detail = client.get(f'/api/v1/runs/{imported.json()["run_id"]}', headers={'X-API-Key': 'test-key'}) + + assert detail.status_code == 200 + assert detail.json()['results'] == [ + { + 'type': 'shodan-host', + 'value': '192.0.2.10', + 'sources': [], + 'actions': ['shodan'], + 'details': details, + } + ] + + +def test_api_schema_exposes_typed_shodan_host_details() -> None: + from theHarvester.lib.api.run_models import NormalizedResult + + schema = NormalizedResult.model_json_schema() + + assert schema['properties']['details']['anyOf'][0] == {'$ref': '#/$defs/ShodanHostDetailsResponse'} + service = schema['$defs']['ShodanServiceResponse'] + assert service['properties']['port']['minimum'] == 1 + assert service['properties']['port']['maximum'] == 65535 + assert service['properties']['transport']['enum'] == ['tcp', 'udp'] + tls = schema['$defs']['ShodanTlsDetailsResponse'] + assert tls['properties']['subject_alt_names']['items'] == {'type': 'string'} + + +def test_api_evidence_rejects_redundant_shodan_host_fields() -> None: + from fastapi import HTTPException + + from theHarvester.lib.api.run_evidence import validate_evidence + + evidence = { + 'target': 'example.test', + 'status': 'complete', + 'results': [ + { + 'type': 'shodan-host', + 'value': '192.0.2.10', + 'sources': [], + 'actions': ['shodan'], + 'details': { + 'ip': '192.0.2.10', + 'services': [{'port': 443, 'transport': 'tcp'}], + }, + } + ], + 'source_executions': [], + 'action_executions': [], + 'artifacts': [], + } + + with pytest.raises(HTTPException, match='unsupported fields'): + validate_evidence(evidence) + + def test_api_database_upload_rejects_vhost_evidence_outside_its_stored_target(tmp_path, monkeypatch) -> None: from theHarvester.lib.api import api from theHarvester.lib.api.run_evidence import parse_jsonl_import diff --git a/tests/lib/test_completed_persistence.py b/tests/lib/test_completed_persistence.py index 4068333b..610e8450 100644 --- a/tests/lib/test_completed_persistence.py +++ b/tests/lib/test_completed_persistence.py @@ -475,6 +475,62 @@ async def test_structured_network_evidence_round_trips_in_the_results_table(tmp_ assert json.loads(stored_details) == network_observation_details(network_observations) +@pytest.mark.asyncio +async def test_shodan_host_evidence_round_trips_without_a_json_string_value(tmp_path) -> None: + from theHarvester.lib.shodan_evidence import ShodanHostObservation + + database = tmp_path / 'stash.sqlite' + store = ResultStore(database) + await store.initialize() + collected_at = datetime(2026, 8, 14, 12, 2, tzinfo=UTC) + shodan_host = ShodanHostObservation.from_record( + '192.0.2.10', + { + 'asn': 'AS64496', + 'organization': 'Example Transit', + 'services': [ + {'port': 53, 'transport': 'udp', 'product': 'dnsmasq'}, + {'port': 443, 'transport': 'tcp', 'product': 'nginx'}, + ], + }, + ) + result = CompletedResult.finish( + target='example.com', + started_at=collected_at, + completed_at=collected_at, + groups={}, + source_executions=(SourceExecution('shodan', 'completed', 1, 1),), + observations=(ResultObservation('shodan', 'shodan-host', '192.0.2.10'),), + active_evidence=ActiveEvidence( + executions=( + ActionExecution.finish( + action='shodan', + status='completed', + duration_ms=1, + groups={'shodan-host': ['192.0.2.10']}, + ), + ) + ), + shodan_hosts=(shodan_host,), + ) + + await store.save_run(result) + + assert await store.load_run(result.run_id) == result + with sqlite3.connect(database) as db: + stored = db.execute( + 'SELECT kind, value, details_json FROM results WHERE run_id = ?', + (str(result.run_id),), + ).fetchone() + origins = db.execute( + 'SELECT COUNT(*) FROM result_origins WHERE run_id = ?', + (str(result.run_id),), + ).fetchone()[0] + assert stored[:2] == ('shodan-host', '192.0.2.10') + assert json.loads(stored[2]) == shodan_host.to_details() + assert origins == 2 + + @pytest.mark.asyncio async def test_asn_organization_attribution_round_trips_in_a_normalized_table(tmp_path) -> None: database = tmp_path / 'stash.sqlite' diff --git a/tests/lib/test_completed_result.py b/tests/lib/test_completed_result.py index 1a9a5443..07df3234 100644 --- a/tests/lib/test_completed_result.py +++ b/tests/lib/test_completed_result.py @@ -1040,6 +1040,125 @@ def test_jsonl_rejects_legacy_vhost_result_kind() -> None: parse_result_jsonl(payload) +def test_shodan_host_jsonl_uses_the_ip_value_and_nonredundant_details() -> None: + from theHarvester.lib.shodan_evidence import ShodanHostObservation + + completed_at = datetime(2026, 8, 14, 12, 2, tzinfo=UTC) + shodan_host = ShodanHostObservation.from_record( + '192.0.2.10', + { + 'asn': 'AS64496', + 'organization': 'Example Transit', + 'isp': 'Example ISP', + 'hostnames': ['api.example.test'], + 'domains': ['example.test'], + 'services': [ + { + 'port': 53, + 'transport': 'udp', + 'product': 'dnsmasq', + 'version': '2.90', + 'observed_at': '2026-08-14T11:58:00Z', + }, + { + 'port': 443, + 'transport': 'tcp', + 'product': 'nginx', + 'version': '1.24.0', + 'observed_at': '2026-08-14T12:01:00Z', + 'http': {'title': 'Example', 'server': 'nginx', 'components': ['nginx']}, + }, + ], + }, + ) + result = CompletedResult.finish( + target='example.com', + started_at=completed_at, + completed_at=completed_at, + groups={}, + active_evidence=ActiveEvidence( + executions=( + ActionExecution.finish( + action='shodan', + status='completed', + duration_ms=1, + groups={'shodan-host': ['192.0.2.10']}, + ), + ) + ), + shodan_hosts=(shodan_host,), + ) + + records = [json.loads(line) for line in result.jsonl().splitlines()] + + assert records[1] == { + 'actions': ['shodan'], + 'details': { + 'asn': 'AS64496', + 'domains': ['example.test'], + 'hostnames': ['api.example.test'], + 'isp': 'Example ISP', + 'organization': 'Example Transit', + 'services': [ + { + 'observed_at': '2026-08-14T11:58:00Z', + 'port': 53, + 'product': 'dnsmasq', + 'transport': 'udp', + 'version': '2.90', + }, + { + 'http': {'components': ['nginx'], 'server': 'nginx', 'title': 'Example'}, + 'observed_at': '2026-08-14T12:01:00Z', + 'port': 443, + 'product': 'nginx', + 'transport': 'tcp', + 'version': '1.24.0', + }, + ], + }, + 'sources': [], + 'type': 'shodan-host', + 'value': '192.0.2.10', + } + summary, findings = parse_result_jsonl(result.jsonl()) + assert summary['result_count'] == 1 + assert findings == records[1:] + + +def test_completed_result_rejects_conflicting_shodan_hosts_for_one_ip() -> None: + from theHarvester.lib.shodan_evidence import ShodanHostObservation + + completed_at = datetime(2026, 8, 14, 12, 2, tzinfo=UTC) + first = ShodanHostObservation.from_record( + '192.0.2.10', + {'services': [{'port': 53, 'transport': 'udp'}]}, + ) + second = ShodanHostObservation.from_record( + '192.0.2.10', + {'services': [{'port': 443, 'transport': 'tcp'}]}, + ) + + with pytest.raises(ValueError, match='conflicting evidence'): + CompletedResult.finish( + target='example.com', + started_at=completed_at, + completed_at=completed_at, + groups={}, + active_evidence=ActiveEvidence( + executions=( + ActionExecution.finish( + action='shodan', + status='completed', + duration_ms=1, + groups={'shodan-host': ['192.0.2.10']}, + ), + ) + ), + shodan_hosts=(first, second), + ) + + def test_completed_result_rejects_artifact_without_a_real_subject_result() -> None: completed_at = datetime(2026, 8, 5, 12, 1, tzinfo=UTC) artifact = ArtifactReference( diff --git a/tests/lib/test_harvestview_ui.py b/tests/lib/test_harvestview_ui.py index 5a8fee13..efdea7d7 100644 --- a/tests/lib/test_harvestview_ui.py +++ b/tests/lib/test_harvestview_ui.py @@ -49,6 +49,32 @@ def test_harvestview_assets_load_outside_the_repository_directory(tmp_path, monk assert 'function renderResults' in response.text +def test_harvestview_has_an_operator_readable_shodan_host_route(tmp_path, monkeypatch) -> None: + from theHarvester.lib.api import api + + monkeypatch.setenv('THEHARVESTER_API_KEY', 'test-key') + monkeypatch.setenv('THEHARVESTER_RUN_DB', str(tmp_path / 'runs.sqlite')) + monkeypatch.setenv('THEHARVESTER_RUN_WORKER', 'disabled') + + with TestClient(api.app, base_url='http://127.0.0.1', client=('127.0.0.1', 50000)) as client: + script = client.get('/static/harvestview/app.js') + + assert script.status_code == 200 + assert "'shodan-host'" in script.text + assert "'Shodan hosts'" in script.text + assert 'function shodanNetworkFormatter' in script.text + assert 'function shodanServicesFormatter' in script.text + assert 'details.hostnames' in script.text + assert 'details.domains' in script.text + assert 'service.observed_at' in script.text + assert 'service.cpes' in script.text + assert 'http.components' in script.text + assert 'service.tls' in script.text + assert 'tls.subject_alt_names' in script.text + assert "title: 'Network'" in script.text + assert "title: 'Services'" in script.text + + def test_harvestview_offers_jsonl_and_sqlite_imports_with_jsonl_export(tmp_path, monkeypatch) -> None: from theHarvester.lib.api import api diff --git a/tests/test_all_source_orchestration.py b/tests/test_all_source_orchestration.py index a53cbfa6..5036d373 100644 --- a/tests/test_all_source_orchestration.py +++ b/tests/test_all_source_orchestration.py @@ -1,5 +1,4 @@ import json -import socket import sys import xml.etree.ElementTree as ElementTree from collections import Counter @@ -166,15 +165,7 @@ async def test_explicit_non_passive_source_is_scheduled_once( return set() if source == 'shodan': - - class FakeShodan: - async def search_ip(self, _ip: str) -> dict: - nonlocal executions - executions += 1 - return {} - - monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', FakeShodan) - monkeypatch.setattr(socket, 'gethostbyname', lambda _domain: '203.0.113.1') + monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', lambda *_args: FakeAdapter()) else: module, constructor_name = { 'criminalip': (theharvester_main.criminalip, 'SearchCriminalIP'), diff --git a/tests/test_main.py b/tests/test_main.py index 8647acdc..8863a6fd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -37,7 +37,8 @@ async def test_cli_help_explains_proxy_and_direct_action_scope( help_text = ' '.join(capsys.readouterr().out.split()) assert exit_info.value.code == 0 - assert 'Use proxies.yaml for supported discovery-source and takeover requests.' in help_text + assert 'Use proxies.yaml for supported discovery-source, Shodan, and takeover requests.' in help_text + assert 'Query the Shodan Host API for discovered IPs, using configured proxies when enabled.' in help_text assert ( 'Enrich discovered IPs with sourced ASN attribution, or an explicitly targeted ASN, IP, or prefix, through ' 'RouteViews.' in help_text @@ -2091,6 +2092,8 @@ async def test_screenshot_capture_failure_cancels_sibling_tasks( @pytest.mark.asyncio async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + from theHarvester.lib.shodan_evidence import ShodanHostObservation + class FakeResultStore: async def initialize(self) -> None: return None @@ -2167,8 +2170,10 @@ async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pyte def __init__(self) -> None: self.attributions: set[AsnAttributionObservation] = set() + self.hosts: dict[str, ShodanHostObservation] = {} - async def search_ip(self, ip: str) -> dict[str, dict[str, list[int]]]: + async def search_ip(self, ip: str, *, proxy: bool = False) -> dict[str, dict[str, object]]: + assert proxy is True self.attributions.add( AsnAttributionObservation( 'action', @@ -2180,11 +2185,22 @@ async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pyte datetime.now(UTC), ) ) - return {ip: {'ports': [443]}} + self.hosts[ip] = ShodanHostObservation.from_record( + ip, + { + 'asn': 'AS64496', + 'organization': 'Example Transit', + 'services': [{'port': 443, 'transport': 'tcp', 'product': 'nginx'}], + }, + ) + return {ip: self.hosts[ip].to_details()} async def get_asn_attributions(self) -> set[AsnAttributionObservation]: return self.attributions + async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]: + return tuple(self.hosts.values()) + class FakeApiScanner: def __init__(self, word: str, wordlist: str, exact_paths: bool = False) -> None: assert word == 'example.com' @@ -2266,7 +2282,12 @@ async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pyte assert isinstance(completed, CompletedResult) assert ('url', 'https://example.com/api/v1') in completed.results assert ('screenshot', 'api.example.com') not in completed.results - assert ('shodan', '{"ip":"192.0.2.10","result":{"ports":[443]}}') in completed.results + assert ('shodan-host', '192.0.2.10') in completed.results + assert completed.shodan_hosts[0].to_details() == { + 'asn': 'AS64496', + 'organization': 'Example Transit', + 'services': [{'port': 443, 'transport': 'tcp', 'product': 'nginx'}], + } assert ('asn', 'AS64496') in completed.results assert completed.asn_attributions[0].organization_label == 'Example Transit' takeover_result = ( @@ -2302,6 +2323,84 @@ async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pyte } +@pytest.mark.asyncio +async def test_shodan_source_evidence_is_not_overwritten_by_conflicting_action_evidence( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from theHarvester.lib.shodan_evidence import ShodanHostObservation + + class ConflictingShodan: + error_type = None + + def __init__(self, word: str | None = None) -> None: + self.word = word + self.host = ( + ShodanHostObservation.from_record( + '192.0.2.10', + {'services': [{'port': 53, 'transport': 'udp'}]}, + ) + if word is not None + else None + ) + + async def process(self, proxy: bool = False) -> None: + return None + + async def get_hostnames(self) -> set[str]: + return {'api.example.com'} if self.word is not None else set() + + async def search_ip(self, ip: str, *, proxy: bool = False) -> dict[str, dict[str, object]]: + self.host = ShodanHostObservation.from_record( + ip, + {'services': [{'port': 443, 'transport': 'tcp'}]}, + ) + return {ip: self.host.to_details()} + + async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]: + return (self.host,) if self.host is not None else () + + async def get_asn_attributions(self) -> set: + if self.word is not None: + return set() + return { + AsnAttributionObservation( + 'action', + 'shodan', + 'AS64496', + 'Example Transit', + 'ip', + '192.0.2.10', + datetime.now(UTC), + ) + } + + monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore) + monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', _ApiHostChecker) + monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', ConflictingShodan) + + result = await theharvester_main.start( + EnumerationOptions( + dns_resolve='192.0.2.53', + domain='example.com', + quiet=True, + shodan=True, + source='shodan', + ), + return_completed_result=True, + ) + + completed = result[-1] + assert completed.shodan_hosts[0].to_details() == {'services': [{'port': 53, 'transport': 'udp'}]} + assert ResultObservation('shodan', 'shodan-host', '192.0.2.10') in completed.observations + execution = next(item for item in completed.active_evidence.executions if item.action == 'shodan') + assert execution.status == 'partial' + assert execution.error_type == 'ValueError' + assert {(observation.kind, observation.value) for observation in execution.observations} == { + ('asn', 'AS64496'), + ('ip', '192.0.2.10'), + } + + @pytest.mark.asyncio @pytest.mark.parametrize( ('request_count', 'request_errors', 'scan_error', 'expected_status', 'expected_error', 'expected_reason'), @@ -2362,17 +2461,14 @@ async def test_shodan_action_records_all_target_errors_as_failed( class FailedShodan: error_type = None - async def search_ip(self, ip: str) -> dict[str, str]: + async def search_ip(self, ip: str, *, proxy: bool = False) -> dict[str, str]: + assert proxy is False raise RuntimeError(f'provider-secret-payload for {ip}') - async def no_sleep(_seconds: float) -> None: - return None - monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore) monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource) monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', _ApiHostChecker) monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', FailedShodan) - monkeypatch.setattr(theharvester_main.asyncio, 'sleep', no_sleep) result = await theharvester_main.start( EnumerationOptions( @@ -2399,17 +2495,14 @@ async def test_shodan_no_data_is_a_completed_zero_yield_action(monkeypatch: pyte class EmptyShodan: error_type = None - async def search_ip(self, _ip: str) -> dict: + async def search_ip(self, _ip: str, *, proxy: bool = False) -> dict: + assert proxy is False return {} - async def no_sleep(_seconds: float) -> None: - return None - monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore) monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource) monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', _ApiHostChecker) monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', EmptyShodan) - monkeypatch.setattr(theharvester_main.asyncio, 'sleep', no_sleep) result = await theharvester_main.start( EnumerationOptions(dns_resolve='192.0.2.53', domain='example.com', quiet=True, shodan=True, source='crtsh'), @@ -2983,6 +3076,8 @@ async def test_takeover_failure_persists_and_propagates( @pytest.mark.asyncio async def test_shodan_cancellation_persists_failure_and_propagates(monkeypatch: pytest.MonkeyPatch) -> None: + from theHarvester.lib.shodan_evidence import ShodanHostObservation + saved: list[CompletedResult] = [] class FakeChecker: @@ -2999,17 +3094,28 @@ async def test_shodan_cancellation_persists_failure_and_propagates(monkeypatch: class CancelledShodan: error_type = None - async def search_ip(self, ip: str) -> dict: - return {ip: {'ports': [443]}} + def __init__(self) -> None: + self.calls = 0 + self.hosts: dict[str, ShodanHostObservation] = {} - async def cancel_during_throttle(_seconds: float) -> None: - raise asyncio.CancelledError + async def search_ip(self, ip: str, *, proxy: bool = False) -> dict: + assert proxy is False + self.calls += 1 + if self.calls == 2: + raise asyncio.CancelledError + self.hosts[ip] = ShodanHostObservation.from_record( + ip, + {'services': [{'port': 443, 'transport': 'tcp'}]}, + ) + return {ip: self.hosts[ip].to_details()} + + async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]: + return tuple(self.hosts.values()) monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved)) monkeypatch.setattr(theharvester_main.crtsh, 'SearchCrtsh', _ApiHostSource) monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker) monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', CancelledShodan) - monkeypatch.setattr(theharvester_main.asyncio, 'sleep', cancel_during_throttle) with pytest.raises(asyncio.CancelledError): await theharvester_main.start( @@ -3035,6 +3141,8 @@ async def test_shodan_cancellation_persists_failure_and_propagates(monkeypatch: async def test_direct_action_checkpoint_cancellation_persists_and_propagates( monkeypatch: pytest.MonkeyPatch, action: str ) -> None: + from theHarvester.lib.shodan_evidence import ShodanHostObservation + checkpoints: list[CompletedResult] = [] saved: list[CompletedResult] = [] @@ -3057,8 +3165,19 @@ async def test_direct_action_checkpoint_cancellation_persists_and_propagates( class FakeShodan: error_type = None - async def search_ip(self, ip: str) -> dict: - return {ip: {'ports': [443]}} + def __init__(self) -> None: + self.hosts: dict[str, ShodanHostObservation] = {} + + async def search_ip(self, ip: str, *, proxy: bool = False) -> dict: + assert proxy is False + self.hosts[ip] = ShodanHostObservation.from_record( + ip, + {'services': [{'port': 443, 'transport': 'tcp'}]}, + ) + return {ip: self.hosts[ip].to_details()} + + async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]: + return tuple(self.hosts.values()) async def cancel_after_action(result: CompletedResult) -> None: if any(execution.action == action for execution in result.active_evidence.executions): diff --git a/tests/test_readme.py b/tests/test_readme.py index 88b5d0d4..3d462bd7 100644 --- a/tests/test_readme.py +++ b/tests/test_readme.py @@ -155,13 +155,17 @@ def test_readme_preserves_project_social_attribution() -> None: assert f'@{handle}' in readme -def test_readme_explains_jsonl_record_and_structured_value_parsing() -> None: +def test_readme_explains_jsonl_record_and_structured_evidence_parsing() -> None: readme = Path('README.md').read_text() assert '{"sources":[],"type":"hostname","value":"api.example.com"}' in readme assert 'select(.type == "dns-recursive-finding") | .value | fromjson' in readme assert 'JSONL is easy to stream one record at a time.' in readme - assert '`person`, `infostealer`, `shodan`, and `takeover`' in readme + assert '`person`, `infostealer`, and `takeover`' in readme + assert 'select(.type == "shodan-host") | {ip: .value, services: .details.services}' in readme + assert 'paginates both hostname and TLS-certificate searches' in readme + assert 'scoped certificate CNs and SANs' in readme + assert 'Raw banners, response bodies, certificate chains, and Shodan crawler metadata are not retained.' in readme assert 'select(.type == "hostname" and .observations) | {hostname: .value, observations}' in readme assert 'Several endpoint observations can enrich the same hostname' in readme assert 'The summary preserves the evidence status, source and action outcomes' in readme diff --git a/theHarvester/__main__.py b/theHarvester/__main__.py index 74b17554..467bf225 100644 --- a/theHarvester/__main__.py +++ b/theHarvester/__main__.py @@ -118,6 +118,7 @@ from theHarvester.lib.recursive_dns import ( from theHarvester.lib.resolver_selection import DEFAULT_DNS_RESOLVERS, normalize_resolver_addresses from theHarvester.lib.result_values import normalize_asn from theHarvester.lib.routeviews import RouteViewsCancelled, RouteViewsResult, enrich_routeviews +from theHarvester.lib.shodan_evidence import ShodanHostObservation, canonical_shodan_hosts from theHarvester.lib.source_catalog import ( SOURCE_SPECS, ActivityClass, @@ -230,7 +231,7 @@ async def start( parser.add_argument( '-p', '--proxies', - help='Use proxies.yaml for supported discovery-source and takeover requests.', + help='Use proxies.yaml for supported discovery-source, Shodan, and takeover requests.', default=False, action='store_true', ) @@ -243,7 +244,7 @@ async def start( parser.add_argument( '-s', '--shodan', - help='Use Shodan to query discovered hosts.', + help='Query the Shodan Host API for discovered IPs, using configured proxies when enabled.', default=False, action='store_true', ) @@ -578,7 +579,8 @@ async def start( screenshot_artifacts: list[ArtifactReference] = [] screenshot_hostnames: set[str] = set() screenshot_ip_addresses: set[str] = set() - shodan_evidence: list[str] = [] + shodan_hosts: dict[str, ShodanHostObservation] = {} + shodan_action_hosts: set[str] = set() takeover_results: dict[str, list[dict[str, str]]] = {} linkedin_people_list_tracker = [] twitter_people_list_tracker = [] @@ -619,6 +621,26 @@ async def start( ) displayed_asn_attributions.update(pending) + def record_shodan_host_observations( + host_observations: Iterable[ShodanHostObservation], + ) -> tuple[ShodanHostObservation, ...]: + canonical_hosts = canonical_shodan_hosts(list(host_observations)) + for host in canonical_hosts: + existing = shodan_hosts.get(host.ip) + if existing is not None and existing != host: + raise ValueError(f'Shodan host {host.ip} has conflicting evidence') + for host in canonical_hosts: + if host.ip not in shodan_hosts: + output_logger.info( + ujson.dumps( + {'type': 'shodan-host', 'value': host.ip, 'details': host.to_details()}, + indent=4, + sort_keys=True, + ) + ) + shodan_hosts[host.ip] = host + return canonical_hosts + def finish_completed_result( *, extra_hostnames: Iterable[str] = (), @@ -672,6 +694,7 @@ async def start( network_observations=network_observations, asn_attributions=asn_attributions, virtual_hosts=vhost_observations if collect_hosts else (), + shodan_hosts=tuple(shodan_hosts.values()), ) except (ValueError, TypeError) as error: output_logger.info(f'[!] An error occurred while completing the result: {error}') @@ -903,6 +926,9 @@ async def start( 'infostealer', (json.dumps(stealer, ensure_ascii=False, separators=(',', ':'), sort_keys=True) for stealer in infostealers), ) + if source == 'shodan': + sourced_shodan_hosts = record_shodan_host_observations(await search_engine.get_shodan_hosts()) + record_source_observations(source, 'shodan-host', (host.ip for host in sourced_shodan_hosts)) async def store(search_engine: Any, source: str) -> None: source_spec = get_source_spec(source) @@ -1632,41 +1658,7 @@ async def start( elif engineitem == 'shodan': try: - shodan_search = shodansearch.SearchShodan() - - # For normal module usage, we need to create a wrapper that works with the store function - class ShodanWrapper: - def __init__(self, domain, shodan_client): - self.word = domain - self.hosts = set() - self.shodan = shodan_client - - async def process(self, use_proxy: bool = False): - import socket - - try: - # Resolve domain to IP and search in Shodan - ip = socket.gethostbyname(self.word) - output_logger.info(f'\tSearching Shodan for {ip}') - result = await self.shodan.search_ip(ip) - if ip in result and isinstance(result[ip], dict): - # Add the IP as a host for consistency with other modules - self.hosts.add(ip) - - for host in result[ip].get('hostnames', []): - self.hosts.add(host) - - output_logger.info(f'Found Shodan data for {ip}') - elif ip in result and isinstance(result[ip], str): - output_logger.info(f'{ip}: {result[ip]}') - except Exception as e: - output_logger.info(f'Error in Shodan search: {e}') - - async def get_hostnames(self): - return list(self.hosts) - - shodan_wrapper = ShodanWrapper(word, shodan_search) - stor_lst.append(store(shodan_wrapper, engineitem)) + stor_lst.append(store(shodansearch.SearchShodan(word), engineitem)) except Exception as e: if isinstance(e, MissingKey): record_missing_credentials(engineitem) @@ -2808,26 +2800,41 @@ async def start( output_logger.info('[+] Note there may be leftover chrome processes you may have to kill manually\n') # Shodan - shodanres = [] + shodanres: list[dict[str, object]] = [] if shodan is True: shodan_started = time.perf_counter() shodan_error_types: set[str] = set() shodan_asns: set[str] = set() shodan_ips: set[str] = set() + + def has_shodan_action_evidence() -> bool: + return bool(shodan_action_hosts or shodan_asns or shodan_ips) + output_logger.info('[*] Searching Shodan. ') try: - for ip_index, ip in enumerate(host_ip): + shodan_search = None + if host_ip: + try: + shodan_search = shodansearch.SearchShodan() + except Exception as init_error: + shodan_error_types.add(type(init_error).__name__) + output_logger.info(f'[SHODAN-error] Error starting Shodan: {type(init_error).__name__}') + for ip in host_ip: + if shodan_search is None: + break try: output_logger.info('\tSearching for ' + ip) - shodan_search = shodansearch.SearchShodan() - shodandict = await shodan_search.search_ip(ip) + shodandict = await shodan_search.search_ip(ip, proxy=use_proxy) get_asn_attributions = getattr(shodan_search, 'get_asn_attributions', None) if get_asn_attributions is not None: collected_attributions = await get_asn_attributions() - asn_attributions.extend(collected_attributions) - shodan_asns.update(attribution.asn for attribution in collected_attributions) - shodan_ips.update(attribution.subject_value for attribution in collected_attributions) - total_asns.extend(attribution.asn for attribution in collected_attributions) + current_attributions = { + attribution for attribution in collected_attributions if attribution.subject_value == ip + } + asn_attributions.extend(current_attributions) + shodan_asns.update(attribution.asn for attribution in current_attributions) + shodan_ips.update(attribution.subject_value for attribution in current_attributions) + total_asns.extend(attribution.asn for attribution in current_attributions) if shodan_search.error_type: shodan_error_types.add(shodan_search.error_type) @@ -2838,21 +2845,12 @@ async def start( # Process the results if it's a dictionary if isinstance(shodan_result, dict): - rowdata = [] - for _key, value in shodan_result.items(): - if isinstance(value, int): - value = str(value) - if isinstance(value, list): - value = ', '.join(map(str, value)) - rowdata.append(value) - shodanres.append(rowdata) - shodan_evidence.append( - json.dumps({'ip': ip, 'result': shodan_result}, separators=(',', ':'), sort_keys=True) + current_hosts = record_shodan_host_observations( + host for host in await shodan_search.get_shodan_hosts() if host.ip == ip ) - output_logger.info(ujson.dumps(shodan_result, indent=4, sort_keys=True)) + shodan_action_hosts.update(host.ip for host in current_hosts) + shodanres.extend({'value': host.ip, 'details': host.to_details()} for host in current_hosts) output_logger.info('\n') - if ip_index + 1 < len(host_ip): - await asyncio.sleep(5) except Exception as ip_error: shodan_error_types.add(type(ip_error).__name__) output_logger.info(f'[SHODAN-error] Error searching {ip}: {type(ip_error).__name__}') @@ -2861,9 +2859,9 @@ async def start( action_executions.append( ActionExecution.finish( action='shodan', - status='partial' if shodan_evidence else 'failed', + status='partial' if has_shodan_action_evidence() else 'failed', duration_ms=(time.perf_counter() - shodan_started) * 1000, - groups={'shodan': shodan_evidence, 'asn': shodan_asns, 'ip': shodan_ips}, + groups={'shodan-host': shodan_action_hosts, 'asn': shodan_asns, 'ip': shodan_ips}, error_type='CancelledError', stop_reason='cancelled', ) @@ -2876,14 +2874,14 @@ async def start( shodan_status = 'skipped' shodan_stop_reason = 'no-input' elif shodan_error_types: - shodan_status = 'partial' if shodan_evidence else 'failed' + shodan_status = 'partial' if has_shodan_action_evidence() else 'failed' shodan_stop_reason = 'target-errors' action_executions.append( ActionExecution.finish( action='shodan', status=shodan_status, duration_ms=(time.perf_counter() - shodan_started) * 1000, - groups={'shodan': shodan_evidence, 'asn': shodan_asns, 'ip': shodan_ips}, + groups={'shodan-host': shodan_action_hosts, 'asn': shodan_asns, 'ip': shodan_ips}, error_type=next(iter(sorted(shodan_error_types)), None), stop_reason=shodan_stop_reason, ) diff --git a/theHarvester/discovery/shodan_internetdb.py b/theHarvester/discovery/shodan_internetdb.py index fe518e9b..780d8609 100644 --- a/theHarvester/discovery/shodan_internetdb.py +++ b/theHarvester/discovery/shodan_internetdb.py @@ -1,9 +1,10 @@ -import asyncio import logging -import socket from ipaddress import ip_address +import aiodns + from theHarvester.lib.core import AsyncFetcher +from theHarvester.lib.hostchecker import resolve_ip_addresses from theHarvester.lib.hostnames import normalize_scoped_hostname logger = logging.getLogger(__name__) @@ -34,27 +35,17 @@ class SearchShodanInternetDB: async def do_search(self) -> None: # Resolve the domain to IP addresses first try: - addr_infos = await asyncio.to_thread(socket.getaddrinfo, self.word, None, socket.AF_UNSPEC, socket.SOCK_STREAM) - except socket.gaierror: + resolved_ips = await resolve_ip_addresses(self.word) + except aiodns.error.DNSError: logger.info(f'Shodan InternetDB: Could not resolve domain {self.word}') return - # Deduplicate IPs from the resolution results - resolved_ips: set[str] = set() - for _family, _type, _proto, _canonname, sockaddr in addr_infos: - ip = sockaddr[0] - if isinstance(ip, str): - try: - resolved_ips.add(str(ip_address(ip))) - except ValueError: - continue - if not resolved_ips: logger.info(f'Shodan InternetDB: No IPs resolved for {self.word}') return # Query InternetDB for each resolved IP - requested_ips = sorted(resolved_ips) + requested_ips = list(resolved_ips) urls = [f'https://internetdb.shodan.io/{ip}' for ip in requested_ips] try: responses = await AsyncFetcher.fetch_all(urls, json=True, proxy=self.proxy) diff --git a/theHarvester/discovery/shodansearch.py b/theHarvester/discovery/shodansearch.py index a441de8d..1a35114e 100644 --- a/theHarvester/discovery/shodansearch.py +++ b/theHarvester/discovery/shodansearch.py @@ -1,16 +1,29 @@ +import asyncio import logging +import re +import socket from collections import OrderedDict from datetime import UTC, datetime from ipaddress import ip_address +from typing import cast -from shodan import Shodan, exception +import aiodns from theHarvester.discovery.constants import MissingKey from theHarvester.lib.asn_attribution import AsnAttributionObservation -from theHarvester.lib.core import Core +from theHarvester.lib.core import AsyncFetcher, Core, FetcherResponse, ResponseStreamError +from theHarvester.lib.hostchecker import resolve_ip_addresses +from theHarvester.lib.hostnames import normalize_scoped_hostname +from theHarvester.lib.shodan_evidence import ShodanHostObservation, canonical_shodan_hosts logger = logging.getLogger(__name__) +HostResult = dict[str, object] +_CERTIFICATE_HOSTNAME = re.compile( + r'(?i)(? None: + API_BASE_URL = 'https://api.shodan.io/shodan/host' + SEARCH_URL = f'{API_BASE_URL}/search' + SEARCH_FIELDS = ','.join( + ( + 'ip_str', + 'asn', + 'domains', + 'hostnames', + 'isp', + 'org', + 'port', + 'transport', + 'product', + 'version', + 'timestamp', + 'cpe', + 'http.title', + 'http.server', + 'http.components', + 'ssl.jarm', + 'ssl.cert.subject', + 'ssl.cert.issuer', + 'ssl.cert.expires', + 'ssl.cert.fingerprint', + 'ssl.cert.extensions', + ) + ) + # Preserve the official SDK's one-request-per-second pacing without retaining its blocking transport. + REQUEST_INTERVAL_SECONDS = 1.0 + REQUEST_TIMEOUT_SECONDS: int | None = None + + def __init__(self, word: str | None = None) -> None: + self.word = word.strip().lower().rstrip('.') if word is not None else None + if self.word is not None and (self.word.startswith('*.') or _CERTIFICATE_HOSTNAME.fullmatch(self.word) is None): + raise ValueError('Shodan discovery target must be a hostname') + self.scope = self.word.removeprefix('www.') if self.word is not None else None self.key = Core.shodan_key() if self.key is None: raise MissingKey('Shodan') - self.api = Shodan(self.key) - self.hostdatarow: list = [] - self.tracker: OrderedDict = OrderedDict() + self.tracker: OrderedDict[str, HostResult | str] = OrderedDict() + self.shodan_hosts: dict[str, ShodanHostObservation] = {} self.error_type: str | None = None self.asn_attributions: set[AsnAttributionObservation] = set() + self.totalhosts: set[str] = set() + self.execution_status: str | None = None + self.stop_reason: str | None = None + self._next_request_at = 0.0 - async def search_ip(self, ip) -> OrderedDict: + async def _fetch_json(self, url: str, params: dict[str, object], proxy: bool) -> FetcherResponse: + loop = asyncio.get_running_loop() + delay = self._next_request_at - loop.time() + if delay > 0: + await asyncio.sleep(delay) + self._next_request_at = loop.time() + self.REQUEST_INTERVAL_SECONDS + return await AsyncFetcher.fetch_json( + url, + params=params, + proxy=proxy, + request_timeout=self.REQUEST_TIMEOUT_SECONDS, + ) + + async def _fetch_host(self, ip: str, proxy: bool) -> FetcherResponse: + return await self._fetch_json(f'{self.API_BASE_URL}/{ip}', {'key': self.key}, proxy) + + def _certificate_name(self, value: object) -> str | None: + if not isinstance(value, str) or not (candidate := value.strip().casefold().rstrip('.')): + return None + if _CERTIFICATE_HOSTNAME.fullmatch(candidate) is None: + return None + wildcard = candidate.startswith('*.') + hostname = candidate.removeprefix('*.') + if self.scope is not None: + hostname = normalize_scoped_hostname(hostname, self.scope) or '' + if not hostname: + return None + else: + try: + ip_address(hostname) + except ValueError: + pass + else: + return None + return f'*.{hostname}' if wildcard else hostname + + def _certificate_names(self, cert: object) -> tuple[set[str], bool]: + if not isinstance(cert, dict): + return set(), cert is not None + invalid_response = False + names: set[str] = set() + subject = cert.get('subject') + if subject is not None and not isinstance(subject, dict): + invalid_response = True + elif isinstance(subject, dict): + if normalized := self._certificate_name(subject.get('CN')): + names.add(normalized) + + extensions = cert.get('extensions') + if extensions is not None and not isinstance(extensions, list): + invalid_response = True + elif isinstance(extensions, list): + for extension in extensions: + if not isinstance(extension, dict): + invalid_response = True + continue + if extension.get('name') != 'subjectAltName': + continue + data = extension.get('data') + if not isinstance(data, str): + invalid_response = True + continue + decoded = re.sub(r'\\x[0-9a-f]{2}', ' ', data, flags=re.IGNORECASE) + for match in _CERTIFICATE_HOSTNAME.finditer(decoded): + if normalized := self._certificate_name(match.group()): + names.add(normalized) + return names, invalid_response + + def _scoped_banner_names(self, banner: dict[str, object]) -> set[str]: + assert self.scope is not None + names: set[str] = set() + for field in ('hostnames', 'domains'): + values = banner.get(field) + if isinstance(values, list): + for value in values: + if normalized := normalize_scoped_hostname(value, self.scope): + names.add(normalized) + ssl = banner.get('ssl') + if isinstance(ssl, dict): + cert_names, _invalid = self._certificate_names(ssl.get('cert')) + names.update(cert_names) + return names + + def _merge_host(self, observation: ShodanHostObservation) -> bool: + existing = self.shodan_hosts.get(observation.ip) + if existing is None: + self.shodan_hosts[observation.ip] = observation + return False + + invalid_response = False + scalars: dict[str, str | None] = {} + for field in ('asn', 'organization', 'isp'): + previous = getattr(existing, field) + current = getattr(observation, field) + if previous is not None and current is not None and previous != current: + invalid_response = True + scalars[field] = previous or current + self.shodan_hosts[observation.ip] = ShodanHostObservation.from_record( + observation.ip, + { + **scalars, + 'hostnames': sorted(set(existing.hostnames) | set(observation.hostnames)), + 'domains': sorted(set(existing.domains) | set(observation.domains)), + 'services': [ + service.to_record() + for service in sorted( + set(existing.services) | set(observation.services), + key=lambda service: service.sort_key(), + ) + ], + }, + ) + return invalid_response + + def _record_host(self, ip: str, results: dict[str, object]) -> bool: + data = results.get('data') + if data == []: + logger.info(f'Shodan: No data found for IP {ip}') + return False + if not isinstance(data, list) or not data: + raise ValueError('invalid Shodan host response') + + invalid_response = False + + def normalized_string(value: object) -> str: + nonlocal invalid_response + if value is None: + return '' + if not isinstance(value, str): + invalid_response = True + return '' + normalized = value.strip() + return '' if normalized == 'None' else normalized + + def normalized_strings(value: object) -> list[str]: + nonlocal invalid_response + if value is None: + return [] + if not isinstance(value, list): + invalid_response = True + return [] + if any(not isinstance(item, str) for item in value): + invalid_response = True + return sorted({normalized_string(item) for item in value if isinstance(item, str)} - {''}) + + services: list[dict[str, object]] = [] + for banner in data: + if not isinstance(banner, dict): + invalid_response = True + continue + port = banner.get('port') + transport = normalized_string(banner.get('transport')).casefold() + if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 65535: + invalid_response = True + continue + if transport not in {'tcp', 'udp'}: + invalid_response = True + continue + + service: dict[str, object] = {'port': port, 'transport': transport} + for provider_field, evidence_field in ( + ('product', 'product'), + ('version', 'version'), + ('timestamp', 'observed_at'), + ): + if value := normalized_string(banner.get(provider_field)): + service[evidence_field] = value + + if cpe_values := normalized_strings(banner.get('cpe')): + service['cpes'] = cpe_values + + http = banner.get('http') + if isinstance(http, dict): + http_details: dict[str, object] = {} + for field in ('title', 'server'): + if value := normalized_string(http.get(field)): + http_details[field] = value + components = http.get('components') + if isinstance(components, dict) and ( + component_values := sorted({normalized_string(component) for component in components} - {''}) + ): + http_details['components'] = component_values + elif components is not None and not isinstance(components, dict): + invalid_response = True + if http_details: + service['http'] = http_details + elif http is not None: + invalid_response = True + + ssl = banner.get('ssl') + if isinstance(ssl, dict): + tls_details: dict[str, object] = {} + if jarm := normalized_string(ssl.get('jarm')): + tls_details['jarm'] = jarm + cert = ssl.get('cert') + if isinstance(cert, dict): + subject = cert.get('subject') + issuer = cert.get('issuer') + fingerprint = cert.get('fingerprint') + if subject is not None and not isinstance(subject, dict): + invalid_response = True + if issuer is not None and not isinstance(issuer, dict): + invalid_response = True + if fingerprint is not None and not isinstance(fingerprint, dict): + invalid_response = True + subject_cn = self._certificate_name(subject.get('CN')) if isinstance(subject, dict) else None + cert_names, invalid_cert = self._certificate_names(cert) + invalid_response = invalid_response or invalid_cert + if subject_cn: + tls_details['subject_cn'] = subject_cn + subject_alt_names = sorted(cert_names - ({subject_cn} if subject_cn else set())) + if subject_alt_names: + tls_details['subject_alt_names'] = subject_alt_names + for field, source in ( + ('issuer_cn', issuer.get('CN') if isinstance(issuer, dict) else None), + ('expires_at', cert.get('expires')), + ('sha256', fingerprint.get('sha256') if isinstance(fingerprint, dict) else None), + ): + if value := normalized_string(source): + tls_details[field] = value + elif cert is not None: + invalid_response = True + if tls_details: + service['tls'] = tls_details + elif ssl is not None: + invalid_response = True + + if service not in services: + services.append(service) + + if not services: + raise ValueError('invalid Shodan host response') + services.sort( + key=lambda service: ( + cast('int', service['port']), + str(service['transport']), + str(service.get('observed_at', '')), + ) + ) + + domain_values = normalized_strings(results.get('domains')) + hostname_values = normalized_strings(results.get('hostnames')) + if self.scope is not None: + domain_values = sorted( + {normalized for value in domain_values if (normalized := normalize_scoped_hostname(value, self.scope))} + ) + hostname_values = sorted( + {normalized for value in hostname_values if (normalized := normalize_scoped_hostname(value, self.scope))} + ) + self.totalhosts.update(value for value in domain_values + hostname_values if value != self.scope) + for service in services: + tls = service.get('tls') + if not isinstance(tls, dict): + continue + for field in ('subject_cn', 'subject_alt_names'): + tls_value = tls.get(field) + values = tls_value if isinstance(tls_value, list) else [tls_value] + self.totalhosts.update( + name for name in values if isinstance(name, str) and not name.startswith('*.') and name != self.scope + ) + asn = normalized_string(results.get('asn')) + organization = normalized_string(results.get('org')) + host_details = { + 'asn': asn, + 'domains': domain_values, + 'hostnames': hostname_values, + 'isp': normalized_string(results.get('isp')), + 'organization': organization, + 'services': services, + } + shodan_host = ShodanHostObservation.from_record(ip, host_details) + invalid_response = self._merge_host(shodan_host) or invalid_response + stored_host = self.shodan_hosts[ip] + self.tracker[ip] = stored_host.to_details() + + if stored_host.asn and stored_host.organization: + self.asn_attributions.add( + AsnAttributionObservation( + 'action', + 'shodan', + stored_host.asn, + stored_host.organization, + 'ip', + ip, + datetime.now(UTC), + ) + ) + return invalid_response + + async def _search_target(self, proxy: bool) -> set[str]: + assert self.scope is not None + error_types: set[str] = set() + for query in (f'hostname:{self.scope}', f'ssl:{self.scope}'): + page = 1 + received = 0 + while True: + try: + response = await self._fetch_json( + self.SEARCH_URL, + { + 'key': self.key, + 'query': query, + 'page': page, + 'minify': 'false', + 'fields': self.SEARCH_FIELDS, + }, + proxy, + ) + except ResponseStreamError as error: + error_types.add( + { + 'invalid-response': 'InvalidResponseError', + 'response-limit': 'ResponseLimitError', + 'transport-error': 'TransportError', + }[error.reason] + ) + break + except Exception as error: + error_types.add(type(error).__name__) + break + if not 200 <= response.status < 300: + error_types.add(f'HTTP{response.status}Error') + break + if not isinstance(response.body, dict): + error_types.add('InvalidResponseError') + break + matches = response.body.get('matches') + total = response.body.get('total') + if not isinstance(matches, list) or isinstance(total, bool) or not isinstance(total, int) or total < 0: + error_types.add('InvalidResponseError') + break + for match in matches: + if not isinstance(match, dict): + error_types.add('InvalidResponseError') + continue + if not self._scoped_banner_names(match): + continue + ip_value = match.get('ip_str') + if not isinstance(ip_value, str): + error_types.add('InvalidResponseError') + continue + try: + ip = str(ip_address(ip_value)) + if self._record_host(ip, {**match, 'data': [match]}): + error_types.add('InvalidResponseError') + except (TypeError, ValueError): + error_types.add('InvalidResponseError') + received += len(matches) + if not matches: + if received < total: + error_types.add('InvalidResponseError') + break + if received >= total: + break + page += 1 + return error_types + + async def search_ip(self, ip: str, *, proxy: bool = False) -> OrderedDict[str, HostResult | str]: self.error_type = None try: - ipaddress = ip - results = self.api.host(ipaddress) - - if not results or 'data' not in results or not results['data']: - logger.info(f'Shodan: No data found for IP {ip}') - return OrderedDict() - asn = '' - domains: list = list() - hostnames: list = list() - ip_str = '' - isp = '' - org = '' - ports: list = list() - title = '' - server = '' - product = '' - technologies: list = list() - - data_first_dict = dict(results['data'][0]) - - if 'ip_str' in data_first_dict: - ip_str += data_first_dict['ip_str'] - - if 'http' in data_first_dict: - http_results_dict = dict(data_first_dict['http']) - if 'title' in http_results_dict: - title_val = str(http_results_dict['title']).strip() - if title_val != 'None': - title += title_val - if 'components' in http_results_dict: - for key in http_results_dict['components'].keys(): - technologies.append(key) - if 'server' in http_results_dict: - server_val = str(http_results_dict['server']).strip() - if server_val != 'None': - server += server_val - - for key, value in results.items(): - if key == 'asn': - if isinstance(value, str): - asn += value - elif value is not None: - asn += str(value) - if key == 'domains': - if isinstance(value, list): - domain_values = [str(domain) for domain in value] - domain_values.sort() - domains.extend(domain_values) - if key == 'hostnames': - if isinstance(value, list): - hostname_values = [str(host).strip() for host in value] - hostname_values.sort() - hostnames.extend(hostname_values) - if key == 'isp': - if isinstance(value, str): - isp += value - elif value is not None: - isp += str(value) - if key == 'org': - org += str(value) - if key == 'ports': - if isinstance(value, list): - port_values = [int(port) for port in value if isinstance(port, int)] - port_values.sort() - ports.extend(port_values) - if key == 'product': - if isinstance(value, str): - product += value - elif isinstance(value, list): - product += ', '.join(str(item) for item in value if item is not None) - - technologies = list(set(technologies)) - - self.tracker[ip] = { - 'asn': asn.strip(), - 'domains': domains, - 'hostnames': hostnames, - 'ip_str': ip_str.strip(), - 'isp': isp.strip(), - 'org': org.strip(), - 'ports': ports, - 'product': product.strip(), - 'server': server.strip(), - 'technologies': technologies, - 'title': title.strip(), - } - organization_label = results.get('org') - if asn.strip() and isinstance(organization_label, str) and organization_label.strip(): - try: - subject_ip = str(ip_address(str(ip).strip())) - self.asn_attributions.add( - AsnAttributionObservation( - 'action', - 'shodan', - asn, - organization_label, - 'ip', - subject_ip, - datetime.now(UTC), - ) - ) - except ValueError: - logger.info('Shodan returned invalid ASN organization attribution') - - return self.tracker - except exception.APIError as error: - if str(error).strip().rstrip('.').casefold() == 'no information available for that ip': - logger.info(f'{ip}: Not in Shodan') - self.tracker[ip] = 'Not in Shodan' - else: - self.error_type = type(error).__name__ - self.tracker[ip] = 'Shodan request failed' - except Exception as e: - self.error_type = type(e).__name__ + normalized_ip = str(ip_address(ip.strip())) + except ValueError: + self.error_type = 'ValueError' self.tracker[ip] = 'Shodan request failed' + return self.tracker + try: + response = await self._fetch_host(normalized_ip, proxy) + if response.status == 404: + logger.info(f'{normalized_ip}: Not in Shodan') + self.tracker[normalized_ip] = 'Not in Shodan' + return self.tracker + if not 200 <= response.status < 300: + self.error_type = f'HTTP{response.status}Error' + self.tracker[normalized_ip] = 'Shodan request failed' + return self.tracker + if not isinstance(response.body, dict): + self.error_type = 'InvalidResponseError' + self.tracker[normalized_ip] = 'Shodan request failed' + return self.tracker + try: + if self._record_host(normalized_ip, response.body): + self.error_type = 'InvalidResponseError' + except (TypeError, ValueError): + self.error_type = 'InvalidResponseError' + self.tracker[normalized_ip] = 'Shodan request failed' + except ResponseStreamError as error: + self.error_type = { + 'invalid-response': 'InvalidResponseError', + 'response-limit': 'ResponseLimitError', + 'transport-error': 'TransportError', + }[error.reason] + self.tracker[normalized_ip] = 'Shodan request failed' + except Exception as error: + self.error_type = type(error).__name__ + self.tracker[normalized_ip] = 'Shodan request failed' return self.tracker async def get_asn_attributions(self) -> set[AsnAttributionObservation]: return self.asn_attributions + + async def get_hostnames(self) -> set[str]: + return self.totalhosts + + async def get_shodan_hosts(self) -> tuple[ShodanHostObservation, ...]: + return canonical_shodan_hosts(list(self.shodan_hosts.values())) + + async def process(self, proxy: bool = False) -> None: + if self.word is None: + raise ValueError('A discovery target is required') + assert self.scope is not None + + self.totalhosts.clear() + self.execution_status = None + self.stop_reason = None + dns_stop_reason: str | None = None + try: + resolved_ips = await resolve_ip_addresses(self.word, family=socket.AF_INET) + if not resolved_ips: + raise ValueError('target has no IPv4 addresses') + except TimeoutError: + resolved_ips = () + dns_stop_reason = 'dns-timeout' + except aiodns.error.DNSError as error: + resolved_ips = () + dns_stop_reason = ( + 'dns-timeout' if error.args and error.args[0] == aiodns.error.ARES_ETIMEOUT else 'dns-resolution-failed' + ) + except ValueError: + resolved_ips = () + dns_stop_reason = 'dns-resolution-failed' + + provider_error_types = await self._search_target(proxy) + for resolved_ip in resolved_ips: + await self.search_ip(resolved_ip, proxy=proxy) + if self.error_type is not None: + provider_error_types.add(self.error_type) + + self.error_type = next(iter(sorted(provider_error_types)), None) + retained_evidence = bool(self.totalhosts or self.shodan_hosts) + if dns_stop_reason is not None: + self.execution_status = 'partial' if retained_evidence else 'failed' + self.stop_reason = dns_stop_reason + return + if provider_error_types: + self.execution_status = 'partial' if retained_evidence else 'failed' + if provider_error_types <= {'HTTP401Error', 'HTTP403Error'}: + self.stop_reason = 'access-denied' + elif provider_error_types == {'HTTP429Error'}: + self.stop_reason = 'rate-limited' + elif len(resolved_ips) == 1: + self.stop_reason = 'provider-error' + else: + self.stop_reason = 'provider-errors' + return + + self.execution_status = 'completed' + self.stop_reason = None if retained_evidence else 'no-results' diff --git a/theHarvester/lib/api/run_evidence.py b/theHarvester/lib/api/run_evidence.py index fdb63e2a..7c1bcf7d 100644 --- a/theHarvester/lib/api/run_evidence.py +++ b/theHarvester/lib/api/run_evidence.py @@ -15,6 +15,7 @@ from theHarvester.lib.network_evidence import ( parse_network_observation_details, ) from theHarvester.lib.result_values import normalize_prefix +from theHarvester.lib.shodan_evidence import ShodanHostObservation from .run_models import _normalize_target @@ -102,6 +103,41 @@ def validate_evidence(evidence: dict[str, Any]) -> dict[str, Any]: status_code=status.HTTP_400_BAD_REQUEST, detail='vhost is not a result type; use hostname with virtual-host observations', ) + if result.get('type') == 'shodan-host': + allowed_keys = {'type', 'value', 'sources', 'actions', 'details'} + if set(result) - allowed_keys: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Shodan host evidence contains unsupported fields', + ) + sources = result.get('sources', []) + actions = result.get('actions', []) + if ( + not isinstance(sources, list) + or any(not isinstance(source, str) or not source.strip() for source in sources) + or not isinstance(actions, list) + or any(not isinstance(action, str) or not action.strip() for action in actions) + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Shodan host producers must be arrays of non-empty strings', + ) + value = result.get('value') + if not isinstance(value, str): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Shodan host evidence must identify a canonical IP address', + ) + try: + shodan_host = ShodanHostObservation.from_record(value, result.get('details')) + if shodan_host.ip != value or shodan_host.to_details() != result.get('details'): + raise ValueError('Shodan host evidence must use canonical structured details') + except ValueError as error: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error + result['sources'] = sorted(set(sources)) + result['actions'] = sorted(set(actions)) + result['details'] = shodan_host.to_details() + continue if result.get('type') == 'prefix': allowed_keys = {'type', 'value', 'sources', 'actions', 'scope', 'observations'} if set(result) - allowed_keys: diff --git a/theHarvester/lib/api/run_models.py b/theHarvester/lib/api/run_models.py index f208c189..e78c8556 100644 --- a/theHarvester/lib/api/run_models.py +++ b/theHarvester/lib/api/run_models.py @@ -402,6 +402,49 @@ class AsnAttributionObservationResponse(BaseModel): collected_at: str +class ShodanHttpDetailsResponse(BaseModel): + model_config = ConfigDict(extra='forbid') + + title: str | None = None + server: str | None = None + components: list[str] = Field(default_factory=list) + + +class ShodanTlsDetailsResponse(BaseModel): + model_config = ConfigDict(extra='forbid') + + subject_cn: str | None = None + subject_alt_names: list[str] = Field(default_factory=list) + issuer_cn: str | None = None + expires_at: str | None = None + sha256: str | None = None + jarm: str | None = None + + +class ShodanServiceResponse(BaseModel): + model_config = ConfigDict(extra='forbid') + + port: int = Field(ge=1, le=65535) + transport: Literal['tcp', 'udp'] + product: str | None = None + version: str | None = None + observed_at: str | None = None + cpes: list[str] = Field(default_factory=list) + http: ShodanHttpDetailsResponse | None = None + tls: ShodanTlsDetailsResponse | None = None + + +class ShodanHostDetailsResponse(BaseModel): + model_config = ConfigDict(extra='forbid') + + asn: str | None = None + organization: str | None = None + isp: str | None = None + hostnames: list[str] = Field(default_factory=list) + domains: list[str] = Field(default_factory=list) + services: list[ShodanServiceResponse] = Field(min_length=1) + + class NormalizedResult(BaseModel): model_config = ConfigDict(extra='forbid') @@ -410,6 +453,10 @@ class NormalizedResult(BaseModel): sources: list[str] = Field(default_factory=list) actions: list[str] = Field(default_factory=list) scope: Literal['external-relationship'] | None = None + details: ShodanHostDetailsResponse | None = Field( + default=None, + description='Canonical host and service evidence for shodan-host results.', + ) observations: ( list[ VirtualHostObservationResponse @@ -425,7 +472,16 @@ class NormalizedResult(BaseModel): def validate_result(self) -> Self: if self.type == 'vhost': raise ValueError('vhost is not a result type; use hostname with virtual-host observations') - if self.type == 'prefix': + if self.type == 'shodan-host': + from theHarvester.lib.shodan_evidence import ShodanHostObservation + + if self.scope is not None or self.observations is not None or self.details is None: + raise ValueError('Shodan host results require details without scope or observations') + shodan_details = self.details.model_dump(exclude_none=True, exclude_defaults=True) + shodan_host = ShodanHostObservation.from_record(self.value, shodan_details) + if shodan_host.ip != self.value or shodan_host.to_details() != shodan_details: + raise ValueError('Shodan host results must use canonical structured details') + elif self.type == 'prefix': if self.scope != 'external-relationship': raise ValueError('Prefix results must have external-relationship scope') if self.observations is not None: @@ -444,6 +500,8 @@ class NormalizedResult(BaseModel): parse_virtual_host_details(self.value, details) else: raise ValueError('Structured observations belong to ASN, hostname, or prefix results') + elif self.details is not None: + raise ValueError('Structured details belong to Shodan host results') return self diff --git a/theHarvester/lib/api/run_projection.py b/theHarvester/lib/api/run_projection.py index 1c63da6a..5d6c8e70 100644 --- a/theHarvester/lib/api/run_projection.py +++ b/theHarvester/lib/api/run_projection.py @@ -42,6 +42,8 @@ def normalized_results(evidence: dict[str, Any] | None) -> list[dict[str, Any]]: result['observations'] = [ dict(observation) for observation in item.get('observations', []) if isinstance(observation, dict) ] + if item.get('type') == 'shodan-host' and isinstance(item.get('details'), dict): + result['details'] = dict(item['details']) if item.get('type') == 'prefix': result['scope'] = str(item.get('scope', '')) results.append(result) diff --git a/theHarvester/lib/api/run_store.py b/theHarvester/lib/api/run_store.py index 7d4869ab..a2a1c35b 100644 --- a/theHarvester/lib/api/run_store.py +++ b/theHarvester/lib/api/run_store.py @@ -19,6 +19,7 @@ from theHarvester.lib.completed_result import ( from theHarvester.lib.database import DuplicateRunError, ResultStore, ResultStoreError, RunLifecycleStore from theHarvester.lib.evidence_types import EXECUTION_STATUSES, EvidenceStatus, ExecutionStatus, ResultKind from theHarvester.lib.network_evidence import NetworkObservation, parse_network_observation_details +from theHarvester.lib.shodan_evidence import ShodanHostObservation from .run_artifacts import RunPaths, read_child_evidence from .run_models import RunRequest, _normalize_target, utc_now @@ -55,6 +56,7 @@ def _completed_result( virtual_hosts: list[VirtualHostObservation] = [] network_observations: list[NetworkObservation] = [] asn_attributions: list[AsnAttributionObservation] = [] + shodan_hosts: list[ShodanHostObservation] = [] for item in results: kind = cast('ResultKind', str(item['type'])) value = str(item['value']) @@ -65,6 +67,8 @@ def _completed_result( network_observations.extend(parse_network_observation_details(value, item.get('observations'))) elif kind == 'asn' and item.get('observations'): asn_attributions.extend(parse_asn_attribution_details(value, item.get('observations'))) + elif kind == 'shodan-host': + shodan_hosts.append(ShodanHostObservation.from_record(value, item.get('details'))) for source in set(item.get('sources', [])): source_name = str(source) source_origins.add(ResultObservation(source_name, kind, value)) @@ -152,6 +156,7 @@ def _completed_result( virtual_hosts=virtual_hosts, network_observations=network_observations, asn_attributions=asn_attributions, + shodan_hosts=shodan_hosts, evidence_status=( cast('EvidenceStatus', str(evidence['status'])) if evidence.get('status') is not None and not execution_status_is_authoritative diff --git a/theHarvester/lib/api/static/harvestview/app.js b/theHarvester/lib/api/static/harvestview/app.js index c1448a02..70c79f91 100644 --- a/theHarvester/lib/api/static/harvestview/app.js +++ b/theHarvester/lib/api/static/harvestview/app.js @@ -4,12 +4,12 @@ const $ = selector => document.querySelector(selector); const $$ = selector => [...document.querySelectorAll(selector)]; const ROUTE_ORDER = [ - 'hostname', 'ip', 'prefix', 'asn', 'email', 'url', 'person', 'person-link', 'takeover', 'shodan', + 'hostname', 'ip', 'prefix', 'asn', 'shodan-host', 'email', 'url', 'person', 'person-link', 'takeover', 'scope-extension', 'external-relationship', 'other' ]; const ROUTE_LABELS = { hostname: 'Hostnames', ip: 'IP addresses', prefix: 'Network prefixes', asn: 'ASNs', email: 'Emails', url: 'URLs', - person: 'People', 'person-link': 'People links', takeover: 'Takeover evidence', shodan: 'Shodan evidence', + person: 'People', 'person-link': 'People links', takeover: 'Takeover evidence', 'shodan-host': 'Shodan hosts', 'scope-extension': 'Scope extensions', 'external-relationship': 'External relationships', other: 'Other' }; const ACTION_FIELDS = {'dns-recursive': 'dns_recursive_depth'}; @@ -470,6 +470,47 @@ return text.includes(query); } + function shodanNetworkFormatter(cell) { + const details = cell.getValue() || {}; + const network = [details.organization, details.asn, details.isp].filter(Boolean).join(' · ') || 'Not recorded'; + const names = [ + details.hostnames?.length ? `Hosts: ${details.hostnames.join(', ')}` : '', + details.domains?.length ? `Domains: ${details.domains.join(', ')}` : '', + ].filter(Boolean); + return `