mirror of
https://github.com/laramies/theHarvester.git
synced 2026-09-22 01:24:56 +02:00
Put IDNA and label validation in normalize_scoped_hostname so the runner, parser, and storage path share one authorized-target boundary. Drop IPv6 zone identifiers at collection instead of failing finish(), and remove leftover BuiltWith and Shodan aliases now that www. is part of the explicit target.
1068 lines
40 KiB
Python
1068 lines
40 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import FrozenInstanceError
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from theHarvester.discovery.constants import MissingKeyError
|
|
from theHarvester.lib.asn_attribution import AsnAttributionObservation
|
|
from theHarvester.lib.completed_result import ResultObservation, SourceExecution
|
|
from theHarvester.lib.source_catalog import SOURCE_SPECS
|
|
from theHarvester.lib.source_execution import SourceExecutionReport
|
|
from theHarvester.lib.source_runner import (
|
|
SOURCE_FACTORIES,
|
|
SourceJob,
|
|
SourceOutcome,
|
|
SourceRequest,
|
|
create_source,
|
|
run_source,
|
|
run_source_jobs,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize('workers', [0, -1, True, 1.5])
|
|
@pytest.mark.asyncio
|
|
async def test_source_jobs_require_a_positive_worker_count(workers: object) -> None:
|
|
with pytest.raises(ValueError, match='source workers must be a positive integer'):
|
|
await run_source_jobs((), workers=workers) # type: ignore[arg-type]
|
|
|
|
|
|
def test_source_contracts_are_immutable() -> None:
|
|
request = SourceRequest('APIS-GURU', 'example.test', 25, 5, True, True)
|
|
job = SourceJob(request)
|
|
outcome = SourceOutcome(SourceExecution('apis-guru', 'completed', 0, 0))
|
|
report = SourceExecutionReport('failed', 'provider-failure')
|
|
|
|
with pytest.raises(FrozenInstanceError):
|
|
request.target = 'changed.test' # type: ignore[misc]
|
|
with pytest.raises(FrozenInstanceError):
|
|
job.request = request # type: ignore[misc]
|
|
with pytest.raises(FrozenInstanceError):
|
|
outcome.observations = () # type: ignore[misc]
|
|
with pytest.raises(FrozenInstanceError):
|
|
report.stop_reason = 'changed' # type: ignore[misc]
|
|
assert request.source == 'apis-guru'
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('status', 'stop_reason', 'message'),
|
|
[
|
|
('skipped', 'missing-key', 'cannot report execution status'),
|
|
('unknown', 'provider-failure', 'cannot report execution status'),
|
|
('failed', '', 'stop reason must not be empty'),
|
|
('failed', ' ', 'stop reason must not be empty'),
|
|
],
|
|
)
|
|
def test_source_execution_report_rejects_invalid_contract_values(
|
|
status: str,
|
|
stop_reason: str,
|
|
message: str,
|
|
) -> None:
|
|
with pytest.raises(ValueError, match=message):
|
|
SourceExecutionReport(status, stop_reason) # type: ignore[arg-type]
|
|
|
|
|
|
def test_source_factories_match_the_catalog() -> None:
|
|
assert set(SOURCE_FACTORIES) == set(SOURCE_SPECS)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('source', 'patch_target', 'expected_args', 'expected_kwargs'),
|
|
[
|
|
('apis-guru', 'theHarvester.lib.source_runner.apisguru.SearchApisGuru', ('example.test', 25), {}),
|
|
('arquivo', 'theHarvester.lib.source_runner.arquivo.SearchArquivo', ('example.test', 25), {}),
|
|
('baidu', 'theHarvester.lib.source_runner.baidusearch.SearchBaidu', ('example.test', 25), {}),
|
|
('bevigil', 'theHarvester.lib.source_runner.bevigil.SearchBeVigil', ('example.test',), {}),
|
|
('brave', 'theHarvester.lib.source_runner.bravesearch.SearchBrave', ('example.test', 25), {}),
|
|
('bufferoverun', 'theHarvester.lib.source_runner.bufferoverun.SearchBufferover', ('example.test',), {}),
|
|
('builtwith', 'theHarvester.lib.source_runner.builtwith.SearchBuiltWith', ('example.test',), {}),
|
|
('censys', 'theHarvester.lib.source_runner.censysearch.SearchCensys', ('example.test', 25), {}),
|
|
('certspotter', 'theHarvester.lib.source_runner.certspottersearch.SearchCertspoter', ('example.test',), {}),
|
|
('commoncrawl', 'theHarvester.lib.source_runner.commoncrawl.SearchCommoncrawl', ('example.test', 25), {}),
|
|
('criminalip', 'theHarvester.lib.source_runner.criminalip.SearchCriminalIP', ('example.test',), {}),
|
|
('crt-name', 'theHarvester.lib.source_runner.crtname.SearchCrtName', ('example.test',), {}),
|
|
('crtsh', 'theHarvester.lib.source_runner.crtsh.SearchCrtsh', ('example.test',), {}),
|
|
('dehashed', 'theHarvester.lib.source_runner.search_dehashed.SearchDehashed', ('example.test',), {'limit': 25}),
|
|
('dnsdb', 'theHarvester.lib.source_runner.dnsdb.SearchDNSDB', ('example.test',), {}),
|
|
(
|
|
'dnsdumpster',
|
|
'theHarvester.lib.source_runner.search_dnsdumpster.SearchDNSDumpster',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
('duckduckgo', 'theHarvester.lib.source_runner.duckduckgosearch.SearchDuckDuckGo', ('example.test', 25), {}),
|
|
('dymo', 'theHarvester.lib.source_runner.dymosearch.SearchDymo', ('example.test',), {}),
|
|
('fofa', 'theHarvester.lib.source_runner.fofa.SearchFofa', ('example.test', 25), {}),
|
|
('fullhunt', 'theHarvester.lib.source_runner.fullhuntsearch.SearchFullHunt', ('example.test',), {}),
|
|
('github-code', 'theHarvester.lib.source_runner.githubcode.SearchGithubCode', ('example.test', 25), {}),
|
|
('gitlab', 'theHarvester.lib.source_runner.gitlabsearch.SearchGitlab', ('example.test', 25), {}),
|
|
(
|
|
'hackertarget',
|
|
'theHarvester.lib.source_runner.hackertarget.SearchHackerTarget',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
(
|
|
'haveibeenpwned',
|
|
'theHarvester.lib.source_runner.haveibeenpwned.SearchHaveIBeenPwned',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
(
|
|
'hibpverified',
|
|
'theHarvester.lib.source_runner.hibpverified.SearchHibpVerified',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
('hudsonrock', 'theHarvester.lib.source_runner.hudsonrocksearch.SearchHudsonRock', ('example.test',), {}),
|
|
('hunter', 'theHarvester.lib.source_runner.huntersearch.SearchHunter', ('example.test', 25, 5), {}),
|
|
('hunterhow', 'theHarvester.lib.source_runner.searchhunterhow.SearchHunterHow', ('example.test', 25), {}),
|
|
('intelx', 'theHarvester.lib.source_runner.intelxsearch.SearchIntelx', ('example.test', 25), {}),
|
|
('leakix', 'theHarvester.lib.source_runner.leakix.SearchLeakix', ('example.test',), {}),
|
|
('leaklookup', 'theHarvester.lib.source_runner.leaklookup.SearchLeakLookup', ('example.test',), {}),
|
|
('mojeek', 'theHarvester.lib.source_runner.mojeek.SearchMojeek', ('example.test', 25), {}),
|
|
('netlas', 'theHarvester.lib.source_runner.netlas.SearchNetlas', ('example.test', 25), {}),
|
|
('onyphe', 'theHarvester.lib.source_runner.onyphe.SearchOnyphe', ('example.test', 25), {}),
|
|
('otx', 'theHarvester.lib.source_runner.otxsearch.SearchOtx', ('example.test',), {}),
|
|
(
|
|
'pentesttools',
|
|
'theHarvester.lib.source_runner.pentesttools.SearchPentestTools',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
(
|
|
'projectdiscovery',
|
|
'theHarvester.lib.source_runner.projectdiscovery.SearchDiscovery',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
('rapiddns', 'theHarvester.lib.source_runner.rapiddns.SearchRapidDns', ('example.test',), {}),
|
|
('robtex', 'theHarvester.lib.source_runner.robtex.SearchRobtex', ('example.test',), {}),
|
|
('rocketreach', 'theHarvester.lib.source_runner.rocketreach.SearchRocketReach', ('example.test', 25), {}),
|
|
(
|
|
'securityTrails',
|
|
'theHarvester.lib.source_runner.securitytrailssearch.SearchSecuritytrail',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
(
|
|
'securityscorecard',
|
|
'theHarvester.lib.source_runner.securityscorecard.SearchSecurityScorecard',
|
|
('example.test', 25),
|
|
{},
|
|
),
|
|
(
|
|
'sherlockeye',
|
|
'theHarvester.lib.source_runner.sherlockeye.SearchSherlockeye',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
('shodan', 'theHarvester.lib.source_runner.shodansearch.SearchShodan', ('example.test',), {}),
|
|
(
|
|
'shodanInternetDB',
|
|
'theHarvester.lib.source_runner.shodan_internetdb.SearchShodanInternetDB',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
('shodanct', 'theHarvester.lib.source_runner.shodanct.SearchShodanCt', ('example.test',), {}),
|
|
('sourcegraph', 'theHarvester.lib.source_runner.sourcegraph.SearchSourcegraph', ('example.test', 25), {}),
|
|
(
|
|
'subdomaincenter',
|
|
'theHarvester.lib.source_runner.subdomaincenter.SubdomainCenter',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
(
|
|
'subdomainfinderc99',
|
|
'theHarvester.lib.source_runner.subdomainfinderc99.SearchSubdomainfinderc99',
|
|
('example.test',),
|
|
{},
|
|
),
|
|
('thc', 'theHarvester.lib.source_runner.thc.SearchThc', ('example.test', 25), {}),
|
|
('tomba', 'theHarvester.lib.source_runner.tombasearch.SearchTomba', ('example.test', 25, 5), {}),
|
|
('urlscan', 'theHarvester.lib.source_runner.urlscan.SearchUrlscan', ('example.test', 25), {}),
|
|
('virustotal', 'theHarvester.lib.source_runner.virustotal.SearchVirustotal', ('example.test', 25), {}),
|
|
(
|
|
'waybackarchive',
|
|
'theHarvester.lib.source_runner.waybackarchive.SearchWaybackarchive',
|
|
('example.test', 25),
|
|
{},
|
|
),
|
|
('whoisxml', 'theHarvester.lib.source_runner.whoisxml.SearchWhoisXML', ('example.test', 25), {}),
|
|
('windvane', 'theHarvester.lib.source_runner.windvane.SearchWindvane', ('example.test', 25), {}),
|
|
('yahoo', 'theHarvester.lib.source_runner.yahoosearch.SearchYahoo', ('example.test', 25), {}),
|
|
('zoomeye', 'theHarvester.lib.source_runner.zoomeyesearch.SearchZoomEye', ('example.test', 25), {}),
|
|
],
|
|
)
|
|
def test_factory_constructor_shapes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
source: str,
|
|
patch_target: str,
|
|
expected_args: tuple[object, ...],
|
|
expected_kwargs: dict[str, object],
|
|
) -> None:
|
|
calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
|
|
|
|
def constructor(*args: object, **kwargs: object) -> object:
|
|
calls.append((args, kwargs))
|
|
return object()
|
|
|
|
monkeypatch.setattr(patch_target, constructor)
|
|
|
|
create_source(SourceRequest(source, 'example.test', 25, 5, True, False))
|
|
|
|
assert calls == [(expected_args, expected_kwargs)]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('source', 'patch_target'),
|
|
[
|
|
('gitlab', 'theHarvester.lib.source_runner.gitlabsearch.SearchGitlab'),
|
|
('windvane', 'theHarvester.lib.source_runner.windvane.SearchWindvane'),
|
|
],
|
|
)
|
|
def test_unlimited_limit_reaches_gitlab_and_windvane_factories(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
source: str,
|
|
patch_target: str,
|
|
) -> None:
|
|
calls: list[tuple[object, ...]] = []
|
|
|
|
def constructor(*args: object) -> object:
|
|
calls.append(args)
|
|
return object()
|
|
|
|
monkeypatch.setattr(patch_target, constructor)
|
|
|
|
create_source(SourceRequest(source, 'example.test', None, 0, False, True))
|
|
|
|
assert calls == [('example.test', None)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_normalizes_only_declared_apis_guru_routes(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeApisGuru:
|
|
def __init__(self, target: str, limit: int) -> None:
|
|
assert (target, limit) == ('example.test', 25)
|
|
|
|
async def process(self, proxy: bool) -> None:
|
|
assert proxy is True
|
|
|
|
async def get_hostnames(self) -> list[str]:
|
|
return ['API.Example.TEST.', 'api.example.test', 'outside.test', 'example.test']
|
|
|
|
async def get_emails(self) -> list[str]:
|
|
return ['User@Example.TEST', 'user@example.test']
|
|
|
|
async def get_urls(self) -> list[str]:
|
|
return ['https://api.example.test/v1', 'https://api.example.test/v1']
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
raise AssertionError('undeclared getter must not be read')
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda request: FakeApisGuru(request.target, request.limit))
|
|
|
|
outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 5, True, True))
|
|
|
|
assert outcome.execution.source == 'apis-guru'
|
|
assert outcome.execution.status == 'completed'
|
|
assert outcome.execution.result_count == 3
|
|
assert outcome.execution.stop_reason is None
|
|
assert outcome.observations == (
|
|
ResultObservation('apis-guru', 'email', 'user@example.test'),
|
|
ResultObservation('apis-guru', 'hostname', 'api.example.test'),
|
|
ResultObservation('apis-guru', 'url', 'https://api.example.test/v1'),
|
|
)
|
|
assert outcome.asn_attributions == ()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_preserves_an_explicit_www_target_boundary(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeApisGuru:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> list[str]:
|
|
return ['www.example.com', 'dev.www.example.com', 'bad_label.www.example.com', 'admin.example.com']
|
|
|
|
async def get_emails(self) -> list[str]:
|
|
return []
|
|
|
|
async def get_urls(self) -> list[str]:
|
|
return []
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: FakeApisGuru())
|
|
|
|
outcome = await run_source(SourceRequest('apis-guru', 'www.example.com', 25, 0, False, True))
|
|
|
|
assert outcome.observations == (ResultObservation('apis-guru', 'hostname', 'dev.www.example.com'),)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_keeps_idn_hostnames_inside_a_unicode_target(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeApisGuru:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> list[str]:
|
|
return [
|
|
'münchen.example.test',
|
|
'api.münchen.example.test',
|
|
'api.xn--mnchen-3ya.example.test',
|
|
'admin.example.test',
|
|
]
|
|
|
|
async def get_emails(self) -> list[str]:
|
|
return []
|
|
|
|
async def get_urls(self) -> list[str]:
|
|
return []
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: FakeApisGuru())
|
|
|
|
outcome = await run_source(SourceRequest('apis-guru', 'münchen.example.test', 25, 0, False, True))
|
|
|
|
assert outcome.observations == (ResultObservation('apis-guru', 'hostname', 'api.xn--mnchen-3ya.example.test'),)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_drops_ipv6_zone_identifiers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeOnyphe:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_ips(self) -> list[str]:
|
|
return ['192.0.2.1', 'fe80::1%eth0', '2001:0db8:0:0:0:0:0:1']
|
|
|
|
async def get_asns(self) -> set[str]:
|
|
return set()
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'onyphe', lambda _request: FakeOnyphe())
|
|
|
|
outcome = await run_source(SourceRequest('onyphe', 'example.test', 25, 0, False, False))
|
|
|
|
assert outcome.observations == (
|
|
ResultObservation('onyphe', 'ip', '192.0.2.1'),
|
|
ResultObservation('onyphe', 'ip', '2001:db8::1'),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_times_construction_and_records_missing_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
ticks = iter((10.0, 10.125))
|
|
events: list[str] = []
|
|
|
|
def clock() -> float:
|
|
events.append('clock')
|
|
return next(ticks)
|
|
|
|
def missing_factory(_request: SourceRequest) -> Any:
|
|
assert events == ['clock']
|
|
raise MissingKeyError('apis-guru')
|
|
|
|
monkeypatch.setattr('theHarvester.lib.source_runner.time.perf_counter', clock)
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', missing_factory)
|
|
|
|
outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True))
|
|
|
|
assert outcome.execution == SourceExecution(
|
|
'apis-guru',
|
|
'skipped',
|
|
125,
|
|
0,
|
|
'MissingKeyError',
|
|
'missing-credentials',
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_reporter_failure_is_sanitized_and_does_not_change_provider_outcome(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
process_called = False
|
|
|
|
class SuccessfulAdapter:
|
|
async def process(self, _proxy: bool) -> None:
|
|
nonlocal process_called
|
|
process_called = True
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'fresh.example.test'}
|
|
|
|
async def get_emails(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return set()
|
|
|
|
def broken_reporter(_request: SourceRequest) -> None:
|
|
raise RuntimeError('sensitive callback payload')
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: SuccessfulAdapter())
|
|
caplog.set_level(logging.WARNING, logger='theHarvester.lib.source_runner')
|
|
|
|
outcome = await run_source(
|
|
SourceRequest('apis-guru', 'example.test', 25, 0, False, True),
|
|
on_started=broken_reporter,
|
|
)
|
|
|
|
assert process_called is True
|
|
assert outcome.execution.status == 'completed'
|
|
assert outcome.execution.error_type is None
|
|
assert outcome.observations == (ResultObservation('apis-guru', 'hostname', 'fresh.example.test'),)
|
|
assert 'Source start reporter failed for apis-guru: RuntimeError' in caplog.text
|
|
assert 'sensitive callback payload' not in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_reporter_cancellation_propagates_without_collecting_pre_process_results(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
cancellation = asyncio.CancelledError('reporter cancelled')
|
|
committed: list[SourceOutcome] = []
|
|
process_called = False
|
|
getter_called = False
|
|
|
|
class UnstartedAdapter:
|
|
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 {'stale.example.test'}
|
|
|
|
def cancelled_reporter(_request: SourceRequest) -> None:
|
|
raise cancellation
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: UnstartedAdapter())
|
|
|
|
with pytest.raises(asyncio.CancelledError) as raised:
|
|
await run_source(
|
|
SourceRequest('apis-guru', 'example.test', 25, 0, False, True),
|
|
commit_cancelled=committed.append,
|
|
on_started=cancelled_reporter,
|
|
)
|
|
|
|
assert raised.value is cancellation
|
|
assert process_called is False
|
|
assert getter_called is False
|
|
assert committed[0].execution.status == 'failed'
|
|
assert committed[0].execution.stop_reason == 'cancelled'
|
|
assert committed[0].observations == ()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_retains_earlier_observations_when_a_later_getter_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
class PartiallyFailingApisGuru:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'API.Example.TEST.'}
|
|
|
|
async def get_emails(self) -> set[str]:
|
|
raise RuntimeError('email projection failed')
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: PartiallyFailingApisGuru())
|
|
|
|
outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True))
|
|
|
|
assert outcome.execution.status == 'partial'
|
|
assert outcome.execution.error_type == 'RuntimeError'
|
|
assert outcome.execution.result_count == 1
|
|
assert outcome.observations == (ResultObservation('apis-guru', 'hostname', 'api.example.test'),)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_reads_retained_adapter_evidence_after_process_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class PartiallyFailingSourcegraph:
|
|
async def process(self, _proxy: bool) -> None:
|
|
raise RuntimeError('stream failed')
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'partial.example.test'}
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'sourcegraph', lambda _request: PartiallyFailingSourcegraph())
|
|
|
|
outcome = await run_source(SourceRequest('sourcegraph', 'example.test', 25, 0, False, True))
|
|
|
|
assert outcome.execution.status == 'partial'
|
|
assert outcome.execution.error_type == 'RuntimeError'
|
|
assert outcome.observations == (ResultObservation('sourcegraph', 'hostname', 'partial.example.test'),)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_reports_normal_zero_yield_as_completed_no_results(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class EmptySourcegraph:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> tuple[()]:
|
|
return ()
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'sourcegraph', lambda _request: EmptySourcegraph())
|
|
|
|
outcome = await run_source(SourceRequest('sourcegraph', 'example.test', 25, 0, False, True))
|
|
|
|
assert outcome.execution.status == 'completed'
|
|
assert outcome.execution.stop_reason == 'no-results'
|
|
assert outcome.execution.result_count == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
('report', 'has_results', 'expected_status', 'expected_reason'),
|
|
[
|
|
(None, False, 'completed', 'no-results'),
|
|
(SourceExecutionReport('completed', 'result-limit'), True, 'completed', 'result-limit'),
|
|
(SourceExecutionReport('completed', 'result-limit'), False, 'completed', 'result-limit'),
|
|
(SourceExecutionReport('failed', 'provider-failure'), True, 'partial', 'provider-failure'),
|
|
(SourceExecutionReport('failed', 'provider-failure'), False, 'failed', 'provider-failure'),
|
|
(SourceExecutionReport('rate-limited', 'http-429'), True, 'partial', 'http-429'),
|
|
(SourceExecutionReport('rate-limited', 'http-429'), False, 'rate-limited', 'http-429'),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_runner_combines_adapter_report_with_normalized_evidence(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
report: SourceExecutionReport | None,
|
|
has_results: bool,
|
|
expected_status: str,
|
|
expected_reason: str | None,
|
|
) -> None:
|
|
class FakeSource:
|
|
async def process(self, _proxy: bool) -> SourceExecutionReport | None:
|
|
return report
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'partial.example.test'} if has_results else set()
|
|
|
|
async def get_emails(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return set()
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: FakeSource())
|
|
|
|
outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True))
|
|
|
|
assert outcome.execution.status == expected_status
|
|
assert outcome.execution.stop_reason == expected_reason
|
|
assert outcome.execution.result_count == int(has_results)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_rejects_legacy_or_untyped_execution_reports(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class InvalidSource:
|
|
async def process(self, _proxy: bool) -> object:
|
|
return {'status': 'failed', 'stop_reason': 'provider-failure'}
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
raise AssertionError('result getters must not run after a contract violation')
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'apis-guru', lambda _request: InvalidSource())
|
|
|
|
outcome = await run_source(SourceRequest('apis-guru', 'example.test', 25, 0, False, True))
|
|
|
|
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_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:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'app.example.test'}
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return {'https://app.example.test'}
|
|
|
|
async def get_frameworks(self) -> set[str]:
|
|
return {'Django'}
|
|
|
|
async def get_languages(self) -> set[str]:
|
|
return {'Python'}
|
|
|
|
async def get_servers(self) -> set[str]:
|
|
return {'nginx'}
|
|
|
|
async def get_cms(self) -> set[str]:
|
|
return {'Wagtail'}
|
|
|
|
async def get_analytics(self) -> set[str]:
|
|
return {'Plausible'}
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'builtwith', lambda _request: FakeBuiltWith())
|
|
|
|
outcome = await run_source(SourceRequest('builtwith', 'example.test', 25, 0, False, True))
|
|
|
|
assert {(item.kind, item.value) for item in outcome.observations} == {
|
|
('analytics', 'Plausible'),
|
|
('cms', 'Wagtail'),
|
|
('framework', 'Django'),
|
|
('hostname', 'app.example.test'),
|
|
('language', 'Python'),
|
|
('server', 'nginx'),
|
|
('url', 'https://app.example.test'),
|
|
}
|
|
assert outcome.execution.result_count == 7
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_collects_hudson_rock_infostealers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
infostealer = {'type': 'employee', 'url': 'https://legacy.example.test'}
|
|
|
|
class FakeHudsonRock:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'portal.example.test'}
|
|
|
|
async def get_emails(self) -> set[str]:
|
|
return {'user@example.test'}
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return {'https://portal.example.test'}
|
|
|
|
async def get_infostealers(self) -> list[dict[str, object]]:
|
|
return [infostealer]
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'hudsonrock', lambda _request: FakeHudsonRock())
|
|
|
|
outcome = await run_source(SourceRequest('hudsonrock', 'example.test', 25, 0, False, True))
|
|
|
|
assert {(item.kind, item.value) for item in outcome.observations} == {
|
|
('email', 'user@example.test'),
|
|
('hostname', 'portal.example.test'),
|
|
('infostealer', '{"type":"employee","url":"https://legacy.example.test"}'),
|
|
}
|
|
assert outcome.execution.result_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_keeps_only_asn_attributions_backed_by_accepted_observations(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
collected_at = datetime.now(UTC)
|
|
|
|
class FakeOnyphe:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
raise AssertionError('no-hosts must not read hostname results')
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return {'192.0.2.1'}
|
|
|
|
async def get_asns(self) -> set[str]:
|
|
return {'AS64500'}
|
|
|
|
async def get_asn_attributions(self) -> set[AsnAttributionObservation]:
|
|
return {
|
|
AsnAttributionObservation('source', 'onyphe', 'AS64500', 'Accepted Org', 'ip', '192.0.2.1', collected_at),
|
|
AsnAttributionObservation('source', 'onyphe', 'AS64501', 'Wrong ASN', 'ip', '192.0.2.1', collected_at),
|
|
AsnAttributionObservation(
|
|
'source', 'onyphe', 'AS64500', 'Excluded Host', 'hostname', 'host.example.test', collected_at
|
|
),
|
|
}
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'onyphe', lambda _request: FakeOnyphe())
|
|
|
|
outcome = await run_source(SourceRequest('onyphe', 'example.test', 25, 0, False, False))
|
|
|
|
assert len(outcome.asn_attributions) == 1
|
|
assert outcome.asn_attributions[0].organization_label == 'Accepted Org'
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_reports_invalid_asn_as_partial_and_keeps_prior_ip(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeOnyphe:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return {'192.0.2.1'}
|
|
|
|
async def get_asns(self) -> set[str]:
|
|
return {'not-an-asn'}
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'onyphe', lambda _request: FakeOnyphe())
|
|
|
|
outcome = await run_source(SourceRequest('onyphe', 'example.test', 25, 0, False, False))
|
|
|
|
assert outcome.execution.status == 'partial'
|
|
assert outcome.execution.error_type == 'ValueError'
|
|
assert outcome.observations == (ResultObservation('onyphe', 'ip', '192.0.2.1'),)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_runner_collects_url_before_later_asn_getter_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeZoomEye:
|
|
async def process(self, _proxy: bool) -> None:
|
|
return None
|
|
|
|
async def get_emails(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_ips(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_people(self) -> set[str]:
|
|
return set()
|
|
|
|
async def get_urls(self) -> set[str]:
|
|
return {'https://portal.example.test'}
|
|
|
|
async def get_asns(self) -> set[str]:
|
|
raise RuntimeError('asn getter failed')
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'zoomeye', lambda _request: FakeZoomEye())
|
|
|
|
outcome = await run_source(SourceRequest('zoomeye', 'example.test', 25, 0, False, False))
|
|
|
|
assert outcome.execution.status == 'partial'
|
|
assert outcome.execution.error_type == 'RuntimeError'
|
|
assert outcome.observations == (ResultObservation('zoomeye', 'url', 'https://portal.example.test'),)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_jobs_use_a_clamped_worker_pool_and_isolate_failures(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
active = 0
|
|
peak = 0
|
|
three_active = asyncio.Event()
|
|
task_names: set[str] = set()
|
|
|
|
class GatedAdapter:
|
|
def __init__(self, source: str) -> None:
|
|
self.source = source
|
|
|
|
async def process(self, _proxy: bool) -> None:
|
|
nonlocal active, peak
|
|
task = asyncio.current_task()
|
|
assert task is not None
|
|
task_names.add(task.get_name())
|
|
active += 1
|
|
peak = max(peak, active)
|
|
if active == 3:
|
|
three_active.set()
|
|
await three_active.wait()
|
|
await asyncio.sleep(0)
|
|
active -= 1
|
|
if self.source == 'apis-guru':
|
|
raise RuntimeError('provider failed')
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {f'{self.source}.example.test'}
|
|
|
|
source_names = ('apis-guru', 'sourcegraph', 'crtsh', 'crt-name')
|
|
for source in source_names:
|
|
monkeypatch.setitem(SOURCE_FACTORIES, source, lambda _request, source=source: GatedAdapter(source))
|
|
jobs = tuple(SourceJob(SourceRequest(source, 'example.test', 25, 0, False, True)) for source in source_names)
|
|
|
|
outcomes = await run_source_jobs(jobs, workers=3)
|
|
|
|
assert peak == 3
|
|
assert task_names == {'source-worker:0', 'source-worker:1', 'source-worker:2'}
|
|
assert [outcome.execution.source for outcome in outcomes] == list(source_names)
|
|
assert outcomes[0].execution.status == 'partial'
|
|
assert outcomes[0].execution.error_type == 'RuntimeError'
|
|
assert all(outcome.execution.status == 'completed' for outcome in outcomes[1:])
|
|
assert not [task for task in asyncio.all_tasks() if task.get_name().startswith('source-worker:') and not task.done()]
|
|
|
|
|
|
@pytest.mark.parametrize('workers', [1, 3, 8])
|
|
@pytest.mark.asyncio
|
|
async def test_source_worker_count_does_not_change_completed_sources_or_results(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
workers: int,
|
|
) -> None:
|
|
starts: list[str] = []
|
|
task_names: set[str] = set()
|
|
|
|
class CompleteAdapter:
|
|
def __init__(self, source: str) -> None:
|
|
self.source = source
|
|
|
|
async def process(self, _proxy: bool) -> None:
|
|
task = asyncio.current_task()
|
|
assert task is not None
|
|
task_names.add(task.get_name())
|
|
starts.append(self.source)
|
|
await asyncio.sleep(0)
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {f'{self.source}.example.test'}
|
|
|
|
source_names = ('apis-guru', 'sourcegraph', 'crtsh', 'crt-name')
|
|
for source in source_names:
|
|
monkeypatch.setitem(SOURCE_FACTORIES, source, lambda _request, source=source: CompleteAdapter(source))
|
|
jobs = tuple(SourceJob(SourceRequest(source, 'example.test', 25, 0, False, True)) for source in source_names)
|
|
|
|
outcomes = await run_source_jobs(jobs, workers=workers)
|
|
|
|
assert sorted(starts) == sorted(source_names)
|
|
assert [outcome.execution.source for outcome in outcomes] == list(source_names)
|
|
assert [outcome.execution.result_count for outcome in outcomes] == [1, 1, 1, 1]
|
|
assert len(task_names) == min(workers, len(jobs))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_source_commits_immutable_partial_outcome_then_propagates(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
committed: list[SourceOutcome] = []
|
|
cancellation = asyncio.CancelledError()
|
|
|
|
class CancelledSourcegraph:
|
|
async def process(self, _proxy: bool) -> None:
|
|
raise cancellation
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {'partial.example.test'}
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'sourcegraph', lambda _request: CancelledSourcegraph())
|
|
|
|
with pytest.raises(asyncio.CancelledError) as raised:
|
|
await run_source(
|
|
SourceRequest('sourcegraph', 'example.test', 25, 0, False, True),
|
|
commit_cancelled=committed.append,
|
|
)
|
|
|
|
assert raised.value is cancellation
|
|
assert len(committed) == 1
|
|
outcome = committed[0]
|
|
assert outcome.execution.status == 'partial'
|
|
assert outcome.execution.error_type == 'CancelledError'
|
|
assert outcome.execution.stop_reason == 'cancelled'
|
|
assert outcome.observations == (ResultObservation('sourcegraph', 'hostname', 'partial.example.test'),)
|
|
with pytest.raises(FrozenInstanceError):
|
|
outcome.observations = () # type: ignore[misc]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_child_cancellation_promptly_cleans_blocking_sibling_and_preserves_original(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
sibling_started = asyncio.Event()
|
|
sibling_cancelled = asyncio.Event()
|
|
cancellation = asyncio.CancelledError('source cancelled')
|
|
|
|
class CancellingAdapter:
|
|
async def process(self, _proxy: bool) -> None:
|
|
await sibling_started.wait()
|
|
raise cancellation
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return set()
|
|
|
|
class BlockingAdapter:
|
|
async def process(self, _proxy: bool) -> None:
|
|
sibling_started.set()
|
|
try:
|
|
await asyncio.Event().wait()
|
|
except asyncio.CancelledError:
|
|
sibling_cancelled.set()
|
|
raise
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return set()
|
|
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'sourcegraph', lambda _request: CancellingAdapter())
|
|
monkeypatch.setitem(SOURCE_FACTORIES, 'crtsh', lambda _request: BlockingAdapter())
|
|
jobs = tuple(SourceJob(SourceRequest(source, 'example.test', 25, 0, False, True)) for source in ('crtsh', 'sourcegraph'))
|
|
|
|
with pytest.raises(asyncio.CancelledError) as raised:
|
|
async with asyncio.timeout(0.5):
|
|
await run_source_jobs(jobs)
|
|
|
|
assert raised.value is cancellation
|
|
assert sibling_cancelled.is_set()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parent_cancellation_commits_active_jobs_and_cleans_structured_tasks(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
started = asyncio.Event()
|
|
started_count = 0
|
|
committed: list[SourceOutcome] = []
|
|
|
|
class BlockingAdapter:
|
|
def __init__(self, source: str) -> None:
|
|
self.source = source
|
|
|
|
async def process(self, _proxy: bool) -> None:
|
|
nonlocal started_count
|
|
started_count += 1
|
|
if started_count == 3:
|
|
started.set()
|
|
await asyncio.Event().wait()
|
|
|
|
async def get_hostnames(self) -> set[str]:
|
|
return {f'{self.source}.example.test'}
|
|
|
|
source_names = ('sourcegraph', 'crtsh', 'crt-name', 'apis-guru')
|
|
for source in source_names:
|
|
monkeypatch.setitem(SOURCE_FACTORIES, source, lambda _request, source=source: BlockingAdapter(source))
|
|
jobs = tuple(SourceJob(SourceRequest(source, 'example.test', 25, 0, False, True)) for source in source_names)
|
|
task = asyncio.create_task(run_source_jobs(jobs, commit=committed.append))
|
|
await started.wait()
|
|
task.cancel('parent-marker')
|
|
|
|
with pytest.raises(asyncio.CancelledError) as raised:
|
|
await task
|
|
|
|
assert raised.value.args == ('parent-marker',)
|
|
assert {outcome.execution.source for outcome in committed} == set(source_names)
|
|
assert all(outcome.execution.stop_reason == 'cancelled' for outcome in committed)
|
|
assert not [task for task in asyncio.all_tasks() if task.get_name().startswith('source-worker:') and not task.done()]
|