fix: reject removed adapter lifecycle state

This commit is contained in:
NotoriousRebel
2026-08-16 00:49:16 -04:00
parent 02deafa6f1
commit de60af1f17
4 changed files with 134 additions and 2 deletions
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
+122
View File
@@ -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:
+10
View File
@@ -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,