diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ed546d..f52c965e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,7 +36,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 -- Centralized passive-source completion reporting in an immutable `SourceExecutionReport` returned by adapters, with the source runner alone deriving partial and no-result outcomes from retained evidence; removed mutable per-adapter `execution_status` and `stop_reason` state. +- Centralized passive-source completion reporting in an immutable `SourceExecutionReport` returned by adapters, with the source runner alone deriving partial and no-result outcomes from retained evidence; removed mutable per-adapter `execution_status` and `stop_reason` state and made the runner reject adapters that still expose either field. - Updated BeVigil, Dymo, FOFA, FullHunt, Hunter.how, Netlas, ONYPHE, SecurityScorecard, SecurityTrails, SherlockEye, SubdomainFinder C99, VirusTotal, WhoisXML, and ZoomEye provider contracts to retain scoped partial evidence and report authentication, quota, transport, HTTP, and malformed-response outcomes truthfully. FOFA and ONYPHE now honor the operator result limit across documented pagination, while ZoomEye uses the current `POST /v2/search` API and no longer stops after five empty result pages. - Replaced runtime takeover fingerprint downloads and global body-substring matches with pinned provider-gated DNS, wildcard controls, and compound HTTP rules. Every checked hostname is now stored as an indicator, no-indicator, or inconclusive outcome with typed DNS, HTTP, rule, and error details in JSONL, SQLite, the API, and HarvestView. Direct checks share one cookie-free HTTP session, keep bounded response bodies, and rely on the whole-run deadline instead of silently inheriting aiohttp's default timeout. - HarvestView now summarizes retained evidence and producer health at a glance, links directly to execution outcomes that need attention, and keeps evidence values ahead of optional actions on mobile. Its source picker reports credential readiness without exposing values, prevents unavailable source selections, and replaces the mobile nested-scroll catalog with collapsible activity groups. diff --git a/docs/wiki/How-to-add-a-new-module.md b/docs/wiki/How-to-add-a-new-module.md index 87878cee..4abc615f 100644 --- a/docs/wiki/How-to-add-a-new-module.md +++ b/docs/wiki/How-to-add-a-new-module.md @@ -25,7 +25,7 @@ An adapter normally provides: Do not return fields the provider did not supply. Normalize and deduplicate before returning results. -Return `None` when the provider conversation completed normally, including a valid zero-result response. Return an immutable `SourceExecutionReport` with a stable provider-specific reason for another terminal condition: `completed` for a successful early stop such as reaching the requested result limit, `failed` for provider or transport failure, `rate-limited` for a terminal rate limit, or `partial` when the provider itself confirms incomplete coverage. Do not add mutable `execution_status` or `stop_reason` fields to an adapter. The source runner owns finalization: it promotes any incomplete report with retained normalized evidence to `partial`, and records a normal zero-result completion as `completed` with `no-results`. +Return `None` when the provider conversation completed normally, including a valid zero-result response. Return an immutable `SourceExecutionReport` with a stable provider-specific reason for another terminal condition: `completed` for a successful early stop such as reaching the requested result limit, `failed` for provider or transport failure, `rate-limited` for a terminal rate limit, or `partial` when the provider itself confirms incomplete coverage. Do not add mutable `execution_status` or `stop_reason` fields to an adapter. The source runner rejects adapters that expose either removed field before execution and rechecks before evidence collection. The source runner owns finalization: it promotes any incomplete report with retained normalized evidence to `partial`, and records a normal zero-result completion as `completed` with `no-results`. ### Own the provider conversation diff --git a/tests/lib/test_source_runner.py b/tests/lib/test_source_runner.py index 8d50fa45..598bbd62 100644 --- a/tests/lib/test_source_runner.py +++ b/tests/lib/test_source_runner.py @@ -488,6 +488,128 @@ async def test_runner_rejects_legacy_or_untyped_execution_reports(monkeypatch: p assert outcome.observations == () +@pytest.mark.asyncio +async def test_runner_rejects_removed_mutable_execution_fields_before_provider_work( + monkeypatch: pytest.MonkeyPatch, +) -> None: + process_called = False + getter_called = False + + class LegacySource: + def __init__(self) -> None: + self.execution_status = 'failed' + self.stop_reason = 'provider-failure' + + async def process(self, _proxy: bool) -> None: + nonlocal process_called + process_called = True + + async def get_hostnames(self) -> set[str]: + nonlocal getter_called + getter_called = True + return set() + + monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: LegacySource()) + + outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True)) + + assert process_called is False + assert getter_called is False + assert outcome.execution.status == 'failed' + assert outcome.execution.error_type == 'ValueError' + assert outcome.execution.result_count == 0 + assert outcome.observations == () + + +@pytest.mark.asyncio +async def test_runner_rejects_mutable_execution_fields_created_during_provider_work( + monkeypatch: pytest.MonkeyPatch, +) -> None: + getter_called = False + + class RuntimeLegacySource: + async def process(self, _proxy: bool) -> None: + self.execution_status = 'failed' + self.stop_reason = 'provider-failure' + + async def get_hostnames(self) -> set[str]: + nonlocal getter_called + getter_called = True + return {'untrusted.example.test'} + + monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: RuntimeLegacySource()) + + outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True)) + + assert getter_called is False + assert outcome.execution.status == 'failed' + assert outcome.execution.error_type == 'ValueError' + assert outcome.execution.result_count == 0 + assert outcome.observations == () + + +@pytest.mark.asyncio +async def test_runner_does_not_collect_evidence_after_legacy_process_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + getter_called = False + + class FailingLegacySource: + async def process(self, _proxy: bool) -> None: + self.execution_status = 'failed' + self.stop_reason = 'provider-failure' + raise RuntimeError('provider failed') + + async def get_hostnames(self) -> set[str]: + nonlocal getter_called + getter_called = True + return {'untrusted.example.test'} + + monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: FailingLegacySource()) + + outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True)) + + assert getter_called is False + assert outcome.execution.status == 'failed' + assert outcome.execution.error_type == 'RuntimeError' + assert outcome.execution.result_count == 0 + assert outcome.observations == () + + +@pytest.mark.asyncio +async def test_runner_does_not_collect_evidence_after_legacy_process_cancellation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + cancellation = asyncio.CancelledError('operator-stop') + getter_called = False + committed: list[SourceOutcome] = [] + + class CancelledLegacySource: + async def process(self, _proxy: bool) -> None: + self.execution_status = 'failed' + self.stop_reason = 'cancelled' + raise cancellation + + async def get_hostnames(self) -> set[str]: + nonlocal getter_called + getter_called = True + return {'untrusted.example.test'} + + monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: CancelledLegacySource()) + + with pytest.raises(asyncio.CancelledError) as raised: + await run_source( + SourceRequest('apis-guru', 'example.test', 25, 0, False, True), + commit_cancelled=committed.append, + ) + + assert raised.value is cancellation + assert getter_called is False + assert committed[0].execution.status == 'failed' + assert committed[0].execution.result_count == 0 + assert committed[0].observations == () + + @pytest.mark.asyncio async def test_runner_collects_builtwith_compatibility_observations(monkeypatch: pytest.MonkeyPatch) -> None: class FakeBuiltWith: diff --git a/theHarvester/lib/source_runner.py b/theHarvester/lib/source_runner.py index b6dbd428..7e7ba135 100644 --- a/theHarvester/lib/source_runner.py +++ b/theHarvester/lib/source_runner.py @@ -235,6 +235,12 @@ def create_source(request: SourceRequest) -> Any: return SOURCE_FACTORIES[get_source_spec(request.source).name](request) +def _reject_removed_execution_fields(source: str, adapter: Any) -> None: + fields = tuple(name for name in ('execution_status', 'stop_reason') if hasattr(adapter, name)) + if fields: + raise ValueError(f'Source {source} exposes removed mutable execution fields: {", ".join(fields)}') + + async def _collect_observations( request: SourceRequest, adapter: Any, @@ -314,6 +320,7 @@ async def run_source( try: source_spec = get_source_spec(request.source) created_adapter = create_source(request) + _reject_removed_execution_fields(source_spec.name, created_adapter) if on_started is not None: try: on_started(request) @@ -324,6 +331,7 @@ async def run_source( adapter = created_adapter report = await adapter.process(request.proxy) process_completed = True + _reject_removed_execution_fields(source_spec.name, adapter) if report is None: status: ExecutionStatus = 'completed' stop_reason = None @@ -364,6 +372,7 @@ async def run_source( except asyncio.CancelledError: if adapter is not None and not process_completed: try: + _reject_removed_execution_fields(request.source, adapter) await _collect_observations( request, adapter, @@ -396,6 +405,7 @@ async def run_source( except Exception as error: if adapter is not None and not process_completed: try: + _reject_removed_execution_fields(request.source, adapter) await _collect_observations( request, adapter,