mirror of
https://github.com/laramies/theHarvester.git
synced 2026-08-17 19:35:40 +02:00
Merge remote-tracking branch 'upstream/dev' into codex/upstream-harvestview-impeccable
# Conflicts: # CHANGELOG.md
This commit is contained in:
+3
-1
@@ -35,9 +35,10 @@ 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 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.
|
||||
- Routed discovery sources through immutable source jobs with bounded `TaskGroup` ownership, typed outcomes, and native cancellation propagation instead of queuing live coroutine objects.
|
||||
- Discovery now uses a fixed pool of six source workers by default. CLI `-j` or `--source-workers`, REST
|
||||
- Discovery now uses a fixed pool of three source workers by default. CLI `-j` or `--source-workers`, REST
|
||||
`source_workers`, and HarvestView can set another positive count without skipping sources or limiting their results.
|
||||
- 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.
|
||||
- Reworked screenshot scans to use one bounded aiohttp session and one shared browser, with isolated per-target contexts, status-based reachability, and deterministic async cleanup.
|
||||
@@ -69,6 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Expanded offline regression coverage for discovery providers, configuration contracts, logging, output, documentation, workflow policy, and scope boundaries.
|
||||
|
||||
### Removed
|
||||
- Removed the mutable runtime takeover fingerprint download and silent handwritten fallback rules.
|
||||
- Removed the inert legacy source identifiers `linkedin`, `netcraft`, `omnisint`, `sublist3r`, and `zoomeyeapi`; use the source catalog and shared factory registry for supported providers.
|
||||
- Removed the obsolete bundled IP-range and resolver snapshots.
|
||||
- Removed the REST API's built-in SlowAPI request limiter and its launcher option without adding a replacement.
|
||||
|
||||
@@ -42,7 +42,7 @@ Query several passive sources:
|
||||
uv run theHarvester -d example.com -b crtsh,certspotter,commoncrawl
|
||||
```
|
||||
|
||||
Six discovery sources run at once by default. Use `-j` or `--source-workers` with a positive number to change that
|
||||
Three discovery sources run at once by default. Use `-j` or `--source-workers` with a positive number to change that
|
||||
concurrency. The worker count never skips a selected source or limits its results, and it is automatically reduced when
|
||||
fewer sources are selected. REST `source_workers` and HarvestView use the same setting.
|
||||
|
||||
@@ -95,6 +95,10 @@ Hostname resolution deduplicates normalized candidates from every selected sourc
|
||||
|
||||
Recursive DNS requires exactly three distinct resolver IPs through `--dns-resolvers` or the compatible `--dns-resolve` value. It advances only names with two-vantage address consensus that are distinguishable from closest-encloser wildcard controls. Depth is required to enable it; the existing query and runtime flags are optional finite overrides, with no default ceiling or zero-yield early stop. PTR names for current addresses are retained as secondary evidence, but they do not establish current addressability or become recursion seeds. HarvestView and `POST /api/v1/runs` expose the same controls.
|
||||
|
||||
Takeover checks start with each canonical in-scope hostname and the configured DNS resolvers. HTTP requests run only after a CNAME matches a pinned, reviewed provider rule. Before making those requests, a random sibling control checks whether the same provider response comes from wildcard DNS; indistinguishable cases are reported as inconclusive instead of findings. Requests keep the original hostname for HTTP `Host` and TLS SNI, do not follow redirects, verify TLS, isolate cookies, and stop at a 1 MiB response safety bound. The action uses at most 20 candidate workers, with no default candidate, request, result, or phase-runtime ceiling. Proxy mode stops before active requests when no configured proxy is available.
|
||||
|
||||
A match is a takeover indicator, not proof that an operator can claim the provider resource. Every checked hostname is retained as one `indicator`, `no-indicator`, or `inconclusive` outcome. JSONL, SQLite, the API, and HarvestView keep the canonical hostname together with its service, rule revision, resolver-specific CNAME chain and terminal RCODE, wildcard control, HTTP status, redirect location, matched predicates, and errors. The bundled rules are a reviewed translation of [can-i-take-over-xyz](https://github.com/EdOverflow/can-i-take-over-xyz) at `5bd4e128` and selected compound predicates from [Nuclei templates](https://github.com/projectdiscovery/nuclei-templates) at `9090ee10`; no rules are downloaded during a run.
|
||||
|
||||
Screenshot capture also requires a Playwright-compatible browser; see the installation guide for setup.
|
||||
|
||||
## HarvestView and REST API
|
||||
@@ -152,7 +156,7 @@ HarvestView can start a screenshot or DNS brute-force run directly from a hostna
|
||||
|
||||
API clients send `THEHARVESTER_API_KEY` in the `X-API-Key` header; HarvestView uses its derived browser cookie. Provider credentials stay in server-side configuration and cannot be supplied in a request. Keep the service bound to localhost. If you require remote access, add network access controls and TLS.
|
||||
|
||||
When `--proxies` and `--take-over` are combined, supported discovery and takeover requests use the configured proxies.
|
||||
When `--proxies` and `--take-over` are combined, takeover requests use a configured proxy or stop before contacting discovered hosts. They never fall back to a direct request.
|
||||
|
||||
## Discovery sources
|
||||
|
||||
@@ -297,7 +301,7 @@ 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. Recursive DNS records plus `person`, `infostealer`, 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` and `infostealer` store a JSON object inside the string `value`; parse those values a second time with `fromjson`. Takeover outcomes instead keep the canonical hostname in `value` and put their typed status, DNS, wildcard, HTTP, rule, and error evidence in `details`.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ run_id="$(curl -s http://127.0.0.1:5000/api/v1/runs \
|
||||
"target": "example.com",
|
||||
"sources": ["emails", "crtsh"],
|
||||
"limit": 500,
|
||||
"source_workers": 6,
|
||||
"source_workers": 3,
|
||||
"deadline_seconds": 1800
|
||||
}' \
|
||||
| jq -r '.run_id')"
|
||||
@@ -79,7 +79,7 @@ curl -s "http://127.0.0.1:5000/api/v1/runs/$run_id" \
|
||||
Run submission is asynchronous. Lifecycle status is `queued`, `running`, `cancelling`, `cancelled`, `completed`, or `failed`. Terminal evidence status is reported separately as `complete`, `partial`, or `failed` when evidence exists.
|
||||
|
||||
`source_workers` is the same positive concurrency used by CLI `-j` or `--source-workers` and HarvestView. It defaults
|
||||
to six, is reduced when fewer sources are selected, and never skips sources or limits their results.
|
||||
to three, is reduced when fewer sources are selected, and never skips sources or limits their results.
|
||||
|
||||
P1 DNS and P2 direct options are fields on the same run request. The OpenAPI schema shows their current defaults, limits, and descriptions. The server uses the operator-selected target and does not impose a public-only egress policy.
|
||||
|
||||
|
||||
@@ -1,38 +1,952 @@
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from ipaddress import ip_address
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from theHarvester.discovery import takeover
|
||||
from theHarvester.lib.core import FetcherResponse
|
||||
from theHarvester.lib.core import FetcherResponse, ResponseStreamError
|
||||
from theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome, TakeoverDNSOutcome
|
||||
from theHarvester.lib.takeover_rules import TakeoverRule
|
||||
|
||||
|
||||
def test_takeover_rules_cover_the_pinned_validated_corpus() -> None:
|
||||
fixture_path = Path(__file__).parents[1] / 'fixtures' / 'takeover_can_i_take_over_xyz_5bd4e128.fixture'
|
||||
fixture = json.loads(fixture_path.read_text())
|
||||
assert fixture['provenance'] == {
|
||||
'repository': 'EdOverflow/can-i-take-over-xyz',
|
||||
'commit': '5bd4e128',
|
||||
'source_sha256': 'a108bf6e6d10d4e4861c4293eef8c224a0fd243ec4f3a39de321de69f284c64f',
|
||||
'selection': 'vulnerable == true and cicd_pass == true',
|
||||
}
|
||||
records = fixture['records']
|
||||
assert len(records) == 18
|
||||
rules_by_service = {rule.service: rule for rule in takeover.TAKEOVER_RULES}
|
||||
|
||||
for record in records:
|
||||
assert record['vulnerable'] is True
|
||||
assert record['cicd_pass'] is True
|
||||
if record['service'] == 'SmartJobBoard':
|
||||
assert all(ip_address(value) for value in record['cname'])
|
||||
assert record['service'] not in rules_by_service
|
||||
continue
|
||||
|
||||
rule = rules_by_service[record['service']]
|
||||
provider_names = []
|
||||
for value in record['cname']:
|
||||
try:
|
||||
ip_address(value)
|
||||
except ValueError:
|
||||
provider_names.append(value)
|
||||
assert provider_names
|
||||
assert all(
|
||||
any(re.search(pattern, name, flags=re.IGNORECASE) for pattern in rule.cname_patterns) for name in provider_names
|
||||
)
|
||||
if record['nxdomain']:
|
||||
assert 'NXDOMAIN' in rule.terminal_rcodes
|
||||
elif record['http_status'] is not None:
|
||||
assert record['http_status'] in rule.status_codes
|
||||
else:
|
||||
fingerprint = record['fingerprint'].casefold()
|
||||
literal_markers = (*rule.body_all, *rule.body_any)
|
||||
assert any(marker.casefold() in fingerprint or fingerprint in marker.casefold() for marker in literal_markers)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_distinguishes_transport_failure_from_successful_empty_body(
|
||||
async def test_takeover_reuses_one_cookie_free_unlimited_http_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
search = takeover.TakeOver(['api.example.com', 'timeout.example.com'])
|
||||
monkeypatch.setattr(search, 'fingerprints', {'No such app': 'Heroku'})
|
||||
class FakeResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def fake_fetch_all(urls, **kwargs):
|
||||
assert kwargs['include_metadata'] is True
|
||||
assert kwargs['headers'] == {'User-Agent': takeover.Core.get_browser_user_agent()}
|
||||
assert set(urls) == {
|
||||
'https://api.example.com',
|
||||
'http://api.example.com',
|
||||
'https://timeout.example.com',
|
||||
'http://timeout.example.com',
|
||||
async def query(self, hostname: str) -> TakeoverDNSOutcome:
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=() if hostname.startswith('takeover-control-') else ('bucket.s3.amazonaws.com',),
|
||||
terminal_rcode='NXDOMAIN' if hostname.startswith('takeover-control-') else 'NOERROR',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
class SharedSession:
|
||||
def __init__(self) -> None:
|
||||
self.close_count = 0
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_count += 1
|
||||
|
||||
shared_session = SharedSession()
|
||||
build_calls: list[dict[str, object]] = []
|
||||
fetch_sessions: list[object] = []
|
||||
|
||||
async def fake_build_session(
|
||||
headers: dict[str, str],
|
||||
client_timeout: aiohttp.ClientTimeout,
|
||||
proxy_url: str | None = None,
|
||||
proxy_type: str | None = None,
|
||||
ssl_context: object = None,
|
||||
cookie_jar: aiohttp.abc.AbstractCookieJar | None = None,
|
||||
) -> SharedSession:
|
||||
build_calls.append(
|
||||
{
|
||||
'headers': headers,
|
||||
'client_timeout': client_timeout,
|
||||
'proxy_url': proxy_url,
|
||||
'proxy_type': proxy_type,
|
||||
'ssl_context': ssl_context,
|
||||
'cookie_jar': cookie_jar,
|
||||
}
|
||||
return [
|
||||
('https://api.example.com', FetcherResponse(body='No such app', status=200, headers={})),
|
||||
('http://api.example.com', FetcherResponse(body='', status=204, headers={})),
|
||||
('https://timeout.example.com', None),
|
||||
('http://timeout.example.com', FetcherResponse(body='not vulnerable', status=200, headers={})),
|
||||
]
|
||||
)
|
||||
return shared_session
|
||||
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_all', fake_fetch_all)
|
||||
async def fake_fetch_text(_url: str, **kwargs: object) -> FetcherResponse:
|
||||
fetch_sessions.append(kwargs['session'])
|
||||
return FetcherResponse(
|
||||
body='The specified bucket does not exist <BucketName>bucket</BucketName>',
|
||||
status=404,
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert await search.process() is None
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FakeResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, '_build_session', fake_build_session)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['bucket.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
|
||||
assert search.request_count == 4
|
||||
assert search.request_error_count == 1
|
||||
assert search.request_error_types == {'TransportError'}
|
||||
assert search.scan_error_type is None
|
||||
assert await search.get_takeover_results() == {'https://api.example.com': [{'No such app': 'Heroku'}]}
|
||||
await scanner.process()
|
||||
|
||||
assert len(build_calls) == 1
|
||||
assert isinstance(build_calls[0]['cookie_jar'], aiohttp.DummyCookieJar)
|
||||
assert build_calls[0]['client_timeout'].total is None
|
||||
assert fetch_sessions == [shared_session, shared_session]
|
||||
assert shared_session.close_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_requires_provider_dns_evidence_and_compound_http_predicates(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
dns_outcomes = {
|
||||
'bucket.example.test': TakeoverDNSOutcome(
|
||||
resolver='1.1.1.1',
|
||||
cname_chain=('missing-bucket.s3.amazonaws.com',),
|
||||
terminal_rcode='NOERROR',
|
||||
),
|
||||
'generic.example.test': TakeoverDNSOutcome(
|
||||
resolver='1.1.1.1',
|
||||
cname_chain=('generic.invalid',),
|
||||
terminal_rcode='NOERROR',
|
||||
),
|
||||
}
|
||||
|
||||
class FakeResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
assert nameserver == '1.1.1.1'
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, hostname: str) -> TakeoverDNSOutcome:
|
||||
return dns_outcomes.get(
|
||||
hostname,
|
||||
TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=(),
|
||||
terminal_rcode='NXDOMAIN',
|
||||
),
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
requested: list[str] = []
|
||||
|
||||
async def fake_fetch_text(url: str, **kwargs: object) -> FetcherResponse:
|
||||
requested.append(url)
|
||||
assert kwargs['follow_redirects'] is False
|
||||
assert kwargs['response_byte_limit'] == takeover.MAX_TAKEOVER_RESPONSE_BYTES
|
||||
return FetcherResponse(
|
||||
body='<Code>NoSuchBucket</Code><BucketName>missing-bucket</BucketName>The specified bucket does not exist',
|
||||
status=404,
|
||||
headers={'content-type': 'application/xml'},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FakeResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
|
||||
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['bucket.example.test', 'generic.example.test:192.0.2.10'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
await scanner.process()
|
||||
|
||||
outcomes = await scanner.get_takeover_outcomes()
|
||||
assert [outcome.hostname for outcome in outcomes] == ['bucket.example.test', 'generic.example.test']
|
||||
bucket, generic = outcomes
|
||||
assert bucket.status == 'indicator'
|
||||
assert bucket.indicators[0].service == 'AWS/S3'
|
||||
assert bucket.indicators[0].classification == 'vulnerable-indicator'
|
||||
assert bucket.indicators[0].scheme == 'https'
|
||||
assert bucket.http[0].status == 404
|
||||
assert set(bucket.indicators[0].matched) == {
|
||||
'body:The specified bucket does not exist',
|
||||
'body:BucketName',
|
||||
}
|
||||
assert generic.status == 'no-indicator'
|
||||
assert generic.indicators == ()
|
||||
assert set(requested) == {'https://bucket.example.test', 'http://bucket.example.test'}
|
||||
assert scanner.candidate_count == 2
|
||||
assert scanner.completed_count == 2
|
||||
assert scanner.request_count == 2
|
||||
assert scanner.request_error_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_keeps_dns_only_nxdomain_evidence_without_http(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, _hostname: str) -> TakeoverDNSOutcome:
|
||||
if _hostname.startswith('takeover-control-'):
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=(),
|
||||
terminal_rcode='NXDOMAIN',
|
||||
)
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=('missing.eu-west-1.elasticbeanstalk.com',),
|
||||
terminal_rcode='NXDOMAIN',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def unexpected_fetch(*_args: object, **_kwargs: object) -> FetcherResponse:
|
||||
raise AssertionError('DNS-only rule must not send an HTTP request')
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FakeResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', unexpected_fetch)
|
||||
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['app.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1', '8.8.8.8'],
|
||||
)
|
||||
await scanner.process()
|
||||
|
||||
outcome = (await scanner.get_takeover_outcomes())[0]
|
||||
assert outcome.status == 'indicator'
|
||||
assert outcome.indicators[0].service == 'AWS/Elastic Beanstalk'
|
||||
assert outcome.indicators[0].matched == ('dns:terminal-rcode=NXDOMAIN',)
|
||||
assert {item.resolver for item in outcome.dns} == {'1.1.1.1', '8.8.8.8'}
|
||||
assert scanner.request_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_fails_closed_when_proxy_mode_has_no_proxy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, '_resolve_proxy', lambda _proxy: (None, None))
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['app.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
|
||||
await scanner.process(proxy=True)
|
||||
|
||||
assert await scanner.get_takeover_outcomes() == ()
|
||||
assert scanner.scan_error_type == 'ProxyUnavailableError'
|
||||
assert scanner.stop_reason == 'proxy-unavailable'
|
||||
assert scanner.completed_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_response_limit_is_partial_and_does_not_stop_siblings(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class FakeResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, hostname: str) -> TakeoverDNSOutcome:
|
||||
if hostname.startswith('takeover-control-'):
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=(),
|
||||
terminal_rcode='NXDOMAIN',
|
||||
)
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=(f'{hostname.split(".")[0]}.s3.amazonaws.com',),
|
||||
terminal_rcode='NOERROR',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def fake_fetch_text(url: str, **_kwargs: object) -> FetcherResponse:
|
||||
if 'large.' in url:
|
||||
raise ResponseStreamError('response-limit', status=200, headers={'location': '/retained'})
|
||||
return FetcherResponse(
|
||||
body='The specified bucket does not exist <BucketName>valid</BucketName>',
|
||||
status=404,
|
||||
headers={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FakeResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['large.example.test', 'valid.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
|
||||
await scanner.process()
|
||||
|
||||
outcomes = await scanner.get_takeover_outcomes()
|
||||
assert [item.hostname for item in outcomes] == ['large.example.test', 'valid.example.test']
|
||||
assert [item.status for item in outcomes] == ['inconclusive', 'indicator']
|
||||
assert outcomes[0].error_types == ('ResponseLimitError',)
|
||||
assert {item.location for item in outcomes[0].http} == {'/retained'}
|
||||
assert scanner.completed_count == 2
|
||||
assert scanner.request_error_count == 2
|
||||
assert scanner.request_error_types == {'ResponseLimitError'}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_suppresses_provider_like_wildcard_dns(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class WildcardResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, _hostname: str) -> TakeoverDNSOutcome:
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=('wildcard.s3.amazonaws.com',),
|
||||
terminal_rcode='NOERROR',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def unexpected_fetch(*_args: object, **_kwargs: object) -> FetcherResponse:
|
||||
raise AssertionError('wildcard-indistinguishable DNS must not trigger HTTP')
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', WildcardResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', unexpected_fetch)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['bucket.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
|
||||
await scanner.process()
|
||||
|
||||
outcomes = await scanner.get_takeover_outcomes()
|
||||
assert len(outcomes) == 1
|
||||
assert outcomes[0].status == 'inconclusive'
|
||||
assert outcomes[0].error_types == ('WildcardIndistinguishableError',)
|
||||
assert scanner.wildcard_indistinguishable_count == 1
|
||||
assert scanner.stop_reason == 'wildcard-indistinguishable'
|
||||
assert scanner.request_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_keeps_distinct_provider_cnames_separate_from_wildcard_dns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class DistinctWildcardResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, hostname: str) -> TakeoverDNSOutcome:
|
||||
cname = (
|
||||
'wildcard-bucket.s3.amazonaws.com' if hostname.startswith('takeover-control-') else 'real-bucket.s3.amazonaws.com'
|
||||
)
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=(cname,),
|
||||
terminal_rcode='NOERROR',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def fake_fetch_text(_url: str, **_kwargs: object) -> FetcherResponse:
|
||||
return FetcherResponse(
|
||||
body='The specified bucket does not exist <BucketName>real-bucket</BucketName>',
|
||||
status=404,
|
||||
headers={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', DistinctWildcardResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['bucket.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
|
||||
await scanner.process()
|
||||
|
||||
outcome = (await scanner.get_takeover_outcomes())[0]
|
||||
assert outcome.status == 'indicator'
|
||||
assert outcome.dns[0].cname_chain == ('real-bucket.s3.amazonaws.com',)
|
||||
assert outcome.wildcard_dns[0].cname_chain == ('wildcard-bucket.s3.amazonaws.com',)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_wildcard_control_failure_is_inconclusive(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class FailedControlResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, hostname: str) -> TakeoverDNSOutcome:
|
||||
if hostname.startswith('takeover-control-'):
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=(),
|
||||
terminal_rcode='ERROR',
|
||||
error_type='TimeoutError',
|
||||
)
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=('bucket.s3.amazonaws.com',),
|
||||
terminal_rcode='NOERROR',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def unexpected_fetch(*_args: object, **_kwargs: object) -> FetcherResponse:
|
||||
raise AssertionError('failed wildcard controls must not trigger HTTP')
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', FailedControlResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', unexpected_fetch)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['bucket.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
|
||||
await scanner.process()
|
||||
|
||||
outcome = (await scanner.get_takeover_outcomes())[0]
|
||||
assert outcome.status == 'inconclusive'
|
||||
assert outcome.error_types == ('TimeoutError', 'WildcardControlError')
|
||||
assert outcome.wildcard_dns[0].error_type == 'TimeoutError'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_marks_resolver_disagreement_inconclusive(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class DisagreeingResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, _hostname: str) -> TakeoverDNSOutcome:
|
||||
cname = 'bucket.s3.amazonaws.com' if self.nameserver == '1.1.1.1' else 'live.provider.example'
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=(cname,),
|
||||
terminal_rcode='NOERROR',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def unexpected_fetch(*_args: object, **_kwargs: object) -> FetcherResponse:
|
||||
raise AssertionError('resolver disagreement must not trigger HTTP')
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', DisagreeingResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', unexpected_fetch)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['bucket.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1', '8.8.8.8'],
|
||||
)
|
||||
|
||||
await scanner.process()
|
||||
|
||||
outcome = (await scanner.get_takeover_outcomes())[0]
|
||||
assert outcome.status == 'inconclusive'
|
||||
assert outcome.error_types == ('ResolverDisagreementError',)
|
||||
assert scanner.inconclusive_count == 1
|
||||
assert scanner.stop_reason == 'incomplete-candidates'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_dns_only_rule_requires_terminal_rcode_agreement(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class ConflictingRcodeResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, _hostname: str) -> TakeoverDNSOutcome:
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=('missing.elasticbeanstalk.com',),
|
||||
terminal_rcode='NXDOMAIN' if self.nameserver == '1.1.1.1' else 'NOERROR',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', ConflictingRcodeResolver)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['app.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1', '8.8.8.8'],
|
||||
)
|
||||
|
||||
await scanner.process()
|
||||
|
||||
outcome = (await scanner.get_takeover_outcomes())[0]
|
||||
assert outcome.status == 'inconclusive'
|
||||
assert outcome.error_types == ('ResolverDisagreementError',)
|
||||
assert outcome.indicators == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_wildcard_control_stays_in_the_authorized_zone(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
queried: list[str] = []
|
||||
|
||||
class ScopedResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, hostname: str) -> TakeoverDNSOutcome:
|
||||
queried.append(hostname)
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=() if hostname.startswith('takeover-control-') else ('bucket.s3.amazonaws.com',),
|
||||
terminal_rcode='NXDOMAIN' if hostname.startswith('takeover-control-') else 'NOERROR',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
async def fake_fetch_text(_url: str, **_kwargs: object) -> FetcherResponse:
|
||||
return FetcherResponse(
|
||||
body='The specified bucket does not exist <BucketName>bucket</BucketName>',
|
||||
status=404,
|
||||
headers={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', ScopedResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, 'fetch_text', fake_fetch_text)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['example.co.uk'],
|
||||
target='example.co.uk',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
|
||||
await scanner.process()
|
||||
|
||||
control = next(hostname for hostname in queried if hostname.startswith('takeover-control-'))
|
||||
assert control.endswith('.example.co.uk')
|
||||
assert (await scanner.get_takeover_outcomes())[0].status == 'indicator'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_distinguishes_nodata_from_required_nxdomain(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class NodataResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, hostname: str) -> TakeoverDNSOutcome:
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=() if hostname.startswith('takeover-control-') else ('missing.elasticbeanstalk.com',),
|
||||
terminal_rcode='NXDOMAIN' if hostname.startswith('takeover-control-') else 'NODATA',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', NodataResolver)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['app.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
|
||||
await scanner.process()
|
||||
|
||||
outcome = (await scanner.get_takeover_outcomes())[0]
|
||||
assert outcome.status == 'no-indicator'
|
||||
assert outcome.indicators == ()
|
||||
|
||||
|
||||
def test_takeover_rule_language_validates_regex_and_all_classifications() -> None:
|
||||
rule = TakeoverRule(
|
||||
'regex-rule',
|
||||
'Regex Provider',
|
||||
(r'(?:^|\.)provider\.example$',),
|
||||
body_regex_all=(r'account\s+not\s+found',),
|
||||
)
|
||||
response = FetcherResponse(body='ACCOUNT not found', status=404, headers={})
|
||||
|
||||
assert takeover._match_http(rule, response) == ('body-regex:account\\s+not\\s+found',)
|
||||
with pytest.raises(ValueError, match='invalid regular expression'):
|
||||
TakeoverRule('bad-regex', 'Bad Regex', ('[',), body_all=('missing',))
|
||||
with pytest.raises(ValueError, match='cannot mix DNS and HTTP predicates'):
|
||||
TakeoverRule(
|
||||
'mixed-rule',
|
||||
'Mixed Provider',
|
||||
(r'(?:^|\.)provider\.example$',),
|
||||
terminal_rcodes=('NXDOMAIN',),
|
||||
body_all=('missing',),
|
||||
)
|
||||
assert {rule.classification for rule in takeover.TAKEOVER_RULES} == {
|
||||
'vulnerable-indicator',
|
||||
'unverified-indicator',
|
||||
'edge-case',
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_dns_resolver_retains_partial_query_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class PartialResolver:
|
||||
def __init__(self, *, nameservers: list[str]) -> None:
|
||||
assert nameservers == ['1.1.1.1']
|
||||
|
||||
async def query_dns(self, _hostname: str, record_type: str) -> object:
|
||||
if record_type == 'A':
|
||||
return SimpleNamespace(answer=[SimpleNamespace(data=SimpleNamespace(addr='192.0.2.10'))])
|
||||
if record_type == 'AAAA':
|
||||
raise takeover.aiodns.error.DNSError(takeover.aiodns.error.ARES_ENODATA, 'no IPv6 data')
|
||||
raise takeover.aiodns.error.DNSError(takeover.aiodns.error.ARES_ETIMEOUT, 'CNAME timed out')
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(takeover.aiodns, 'DNSResolver', PartialResolver)
|
||||
|
||||
resolver = takeover.TakeoverDNSResolver('1.1.1.1')
|
||||
outcome = await resolver.query('app.example.test')
|
||||
await resolver.close()
|
||||
|
||||
assert outcome.terminal_rcode == 'NOERROR'
|
||||
assert outcome.error_type == 'DNSError'
|
||||
|
||||
|
||||
def test_takeover_evidence_rejects_runtime_impossible_records() -> None:
|
||||
dns = {
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': ['bucket.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
base = {
|
||||
'status': 'no-indicator',
|
||||
'dns': [dns],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': [],
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match='one DNS outcome per resolver'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'dns': [dns, {**dns, 'terminal_rcode': 'NXDOMAIN'}],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='one HTTP outcome per scheme'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'http': [
|
||||
{'scheme': 'https', 'status': 404},
|
||||
{'scheme': 'https', 'status': 503},
|
||||
],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='response status or error type'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'http': [{'scheme': 'https'}],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='DNS ERROR outcomes require an error type'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'dns': [{**dns, 'terminal_rcode': 'ERROR'}],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='include every DNS and HTTP outcome error'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'http': [{'scheme': 'https', 'error_type': 'TransportError'}],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='wildcard control per resolver'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='candidate DNS agreement across resolvers'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'dns': [
|
||||
dns,
|
||||
{
|
||||
'resolver': '8.8.8.8',
|
||||
'cname_chain': ['different.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
},
|
||||
],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
},
|
||||
{
|
||||
'resolver': '8.8.8.8',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
},
|
||||
],
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='wildcard DNS agreement across resolvers'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'dns': [dns, {**dns, 'resolver': '8.8.8.8'}],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
},
|
||||
{
|
||||
'resolver': '8.8.8.8',
|
||||
'cname_chain': ['wildcard.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
},
|
||||
],
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='successful outcome for their matching scheme'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'status': 'indicator',
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
],
|
||||
'http': [{'scheme': 'http', 'status': 404}],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/S3',
|
||||
'rule_id': 'aws-s3',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['body:BucketName'],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='cannot mix DNS and HTTP predicates'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'status': 'indicator',
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/S3',
|
||||
'rule_id': 'aws-s3',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['body:BucketName', 'dns:terminal-rcode=NOERROR'],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='wildcard control per resolver'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'status': 'indicator',
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/S3',
|
||||
'rule_id': 'aws-s3',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['body:BucketName'],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
wildcard = {
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
with pytest.raises(ValueError, match='agree with every resolver outcome'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'status': 'indicator',
|
||||
'wildcard_dns': [wildcard],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/Elastic Beanstalk',
|
||||
'rule_id': 'aws-elastic-beanstalk',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'matched': ['dns:terminal-rcode=NXDOMAIN'],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
with pytest.raises(ValueError, match='status predicates must agree'):
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
**base,
|
||||
'status': 'indicator',
|
||||
'wildcard_dns': [wildcard],
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'unverified-indicator',
|
||||
'service': 'Helprace',
|
||||
'rule_id': 'helprace',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['status:301'],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_reports_http_session_close_failures_truthfully(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class Resolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
class SharedSession:
|
||||
async def close(self) -> None:
|
||||
raise RuntimeError('close failed')
|
||||
|
||||
async def fake_build_session(*_args: object, **_kwargs: object) -> SharedSession:
|
||||
return SharedSession()
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', Resolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, '_build_session', fake_build_session)
|
||||
scanner = takeover.TakeoverScanner([], target='example.test', nameservers=['1.1.1.1'])
|
||||
|
||||
await scanner.process()
|
||||
|
||||
assert scanner.scan_error_type == 'RuntimeError'
|
||||
assert scanner.stop_reason == 'http-session-close-error'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_cancellation_closes_resolvers_and_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
started = asyncio.Event()
|
||||
resolver_closed = asyncio.Event()
|
||||
session_close_started = asyncio.Event()
|
||||
allow_session_close = asyncio.Event()
|
||||
session_closed = asyncio.Event()
|
||||
|
||||
class BlockingResolver:
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = nameserver
|
||||
|
||||
async def query(self, _hostname: str) -> TakeoverDNSOutcome:
|
||||
started.set()
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError('unreachable')
|
||||
|
||||
async def close(self) -> None:
|
||||
resolver_closed.set()
|
||||
|
||||
class SharedSession:
|
||||
async def close(self) -> None:
|
||||
session_close_started.set()
|
||||
await allow_session_close.wait()
|
||||
session_closed.set()
|
||||
|
||||
async def fake_build_session(*_args: object, **_kwargs: object) -> SharedSession:
|
||||
return SharedSession()
|
||||
|
||||
monkeypatch.setattr(takeover, 'TakeoverDNSResolver', BlockingResolver)
|
||||
monkeypatch.setattr(takeover.AsyncFetcher, '_build_session', fake_build_session)
|
||||
scanner = takeover.TakeoverScanner(
|
||||
['app.example.test'],
|
||||
target='example.test',
|
||||
nameservers=['1.1.1.1'],
|
||||
)
|
||||
task = asyncio.create_task(scanner.process())
|
||||
await started.wait()
|
||||
|
||||
task.cancel('operator-stop')
|
||||
await session_close_started.wait()
|
||||
task.cancel('later-stop')
|
||||
allow_session_close.set()
|
||||
with pytest.raises(asyncio.CancelledError, match='operator-stop'):
|
||||
await task
|
||||
|
||||
assert resolver_closed.is_set()
|
||||
assert session_closed.is_set()
|
||||
assert not [task for task in asyncio.all_tasks() if task.get_name().startswith('takeover-')]
|
||||
|
||||
@@ -467,6 +467,123 @@ def test_harvestview_renders_grouped_virtual_host_observations(
|
||||
expect(page.get_by_role('button', name='DNS brute force admin.example.com (P1)')).to_be_visible()
|
||||
|
||||
|
||||
def test_harvestview_renders_and_filters_takeover_outcomes(
|
||||
harvestview_server_url: str,
|
||||
page: Page,
|
||||
) -> None:
|
||||
run = {
|
||||
'run_id': 'takeover-run',
|
||||
'target': 'example.test',
|
||||
'status': 'completed',
|
||||
'origin': 'local',
|
||||
'created_at': '2026-08-15T12:00:00+00:00',
|
||||
'started_at': '2026-08-15T12:00:01+00:00',
|
||||
'completed_at': '2026-08-15T12:00:05+00:00',
|
||||
'cancellation_requested_at': None,
|
||||
'evidence_status': 'complete',
|
||||
'result_count': 2,
|
||||
'activities': ['P0', 'P2'],
|
||||
'sources': ['crtsh'],
|
||||
'request': {'sources': ['crtsh'], 'limit': 25, 'deadline_seconds': 300, 'takeover': True},
|
||||
'source_executions': [],
|
||||
'action_executions': [
|
||||
{'action': 'takeover', 'status': 'partial', 'result_count': 2, 'duration_ms': 125},
|
||||
],
|
||||
'results': [
|
||||
{
|
||||
'type': 'takeover',
|
||||
'value': 'bucket.example.test',
|
||||
'sources': [],
|
||||
'actions': ['takeover'],
|
||||
'details': {
|
||||
'status': 'indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': ['missing-bucket.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
'error_type': None,
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
'error_type': None,
|
||||
}
|
||||
],
|
||||
'http': [
|
||||
{
|
||||
'scheme': 'https',
|
||||
'status': 404,
|
||||
'location': None,
|
||||
'error_type': None,
|
||||
'body_truncated': False,
|
||||
}
|
||||
],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/S3',
|
||||
'rule_id': 'aws-s3',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['body:BucketName', 'body:The specified bucket does not exist'],
|
||||
}
|
||||
],
|
||||
'error_types': [],
|
||||
},
|
||||
},
|
||||
{
|
||||
'type': 'takeover',
|
||||
'value': 'uncertain.example.test',
|
||||
'sources': [],
|
||||
'actions': ['takeover'],
|
||||
'details': {
|
||||
'status': 'inconclusive',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '8.8.8.8',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'ERROR',
|
||||
'error_type': 'DNSTimeoutError',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': ['DNSTimeoutError'],
|
||||
},
|
||||
},
|
||||
],
|
||||
'screenshots': [],
|
||||
'log': '',
|
||||
'error': None,
|
||||
}
|
||||
|
||||
page.route(f'{harvestview_server_url}/api/v1/runs', lambda route: route.fulfill(json=[run]))
|
||||
page.route(f'{harvestview_server_url}/api/v1/runs/takeover-run', lambda route: route.fulfill(json=run))
|
||||
page.goto(f'{harvestview_server_url}/')
|
||||
|
||||
page.get_by_role('button', name='Takeover outcomes 2').click()
|
||||
rows = page.locator('.tabulator-row')
|
||||
expect(rows).to_have_count(2)
|
||||
expect(rows.first).to_contain_text('bucket.example.test')
|
||||
expect(rows.first).to_contain_text('vulnerable indicator · AWS/S3 · aws-s3@takeover-rules-v1 · HTTPS')
|
||||
expect(rows.first).to_contain_text('Candidate 1.1.1.1 · CNAME missing-bucket.s3.amazonaws.com · NOERROR')
|
||||
expect(rows.first).to_contain_text('Wildcard control 1.1.1.1 · No CNAME · NXDOMAIN')
|
||||
expect(rows.first).to_contain_text('HTTPS · HTTP 404')
|
||||
expect(rows.first).not_to_contain_text('DNS: not captured')
|
||||
|
||||
page.get_by_placeholder('Filter status or errors').press_sequentially('DNSTimeoutError')
|
||||
visible_rows = page.locator('.tabulator-row:visible')
|
||||
expect(visible_rows).to_have_count(1)
|
||||
expect(visible_rows.first).to_contain_text('uncertain.example.test')
|
||||
expect(visible_rows.first).to_contain_text('inconclusive')
|
||||
expect(visible_rows.first).to_contain_text('DNSTimeoutError')
|
||||
|
||||
|
||||
def test_harvestview_renders_sourced_asn_organization_attribution(
|
||||
harvestview_server_url: str,
|
||||
page: Page,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"provenance": {
|
||||
"repository": "EdOverflow/can-i-take-over-xyz",
|
||||
"commit": "5bd4e128",
|
||||
"source_sha256": "a108bf6e6d10d4e4861c4293eef8c224a0fd243ec4f3a39de321de69f284c64f",
|
||||
"selection": "vulnerable == true and cicd_pass == true"
|
||||
},
|
||||
"records": [
|
||||
{"service": "AWS/Elastic Beanstalk", "cname": ["elasticbeanstalk.com"], "fingerprint": "NXDOMAIN", "http_status": null, "nxdomain": true, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "AWS/S3", "cname": ["s3.amazonaws.com"], "fingerprint": "The specified bucket does not exist", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Airee.ru", "cname": ["airee.ru"], "fingerprint": "Ошибка 402. Сервис Айри.рф не оплачен", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Anima", "cname": ["animaapp.io"], "fingerprint": "The page you were looking for does not exist.", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Bitbucket", "cname": ["bitbucket.io"], "fingerprint": "Repository not found", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Discourse", "cname": ["trydiscourse.com"], "fingerprint": "NXDOMAIN", "http_status": null, "nxdomain": true, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "HatenaBlog", "cname": ["hatenablog.com"], "fingerprint": "404 Blog is not found", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Help Juice", "cname": ["helpjuice.com"], "fingerprint": "We could not find what you're looking for.", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Help Scout", "cname": ["helpscoutdocs.com"], "fingerprint": "No settings were found for this company:", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Helprace", "cname": ["helprace.com"], "fingerprint": "HTTP_STATUS=301", "http_status": 301, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Microsoft Azure", "cname": ["cloudapp.net", "cloudapp.azure.com", "azurewebsites.net", "blob.core.windows.net", "cloudapp.azure.com", "azure-api.net", "azurehdinsight.net", "azureedge.net", "azurecontainer.io", "database.windows.net", "azuredatalakestore.net", "search.windows.net", "azurecr.io", "redis.cache.windows.net", "azurehdinsight.net", "servicebus.windows.net", "visualstudio.com"], "fingerprint": "NXDOMAIN", "http_status": null, "nxdomain": true, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "SmartJobBoard", "cname": ["52.16.160.97"], "fingerprint": "This job board website is either expired or its domain name is invalid.", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Strikingly", "cname": ["s.strikinglydns.com"], "fingerprint": "PAGE NOT FOUND.", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Surge.sh", "cname": ["na-west1.surge.sh"], "fingerprint": "project not found", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "SurveySparrow", "cname": ["surveysparrow.com"], "fingerprint": "Account not found.", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Uberflip", "cname": ["read.uberflip.com"], "fingerprint": "The URL you've accessed does not provide a hub.", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Wordpress", "cname": ["wordpress.com"], "fingerprint": "Do you want to register .*.wordpress.com?", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true},
|
||||
{"service": "Worksites", "cname": ["worksites.net", "69.164.223.206"], "fingerprint": "Hello! Sorry, but the website you’re looking for doesn’t exist.", "http_status": null, "nxdomain": false, "vulnerable": true, "cicd_pass": true}
|
||||
]
|
||||
}
|
||||
@@ -1136,6 +1136,231 @@ def test_api_rejects_virtual_host_evidence_outside_the_run_scope(
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rejects_takeover_evidence_outside_the_run_scope(tmp_path, monkeypatch) -> None:
|
||||
from theHarvester.lib.api import api
|
||||
|
||||
details = {
|
||||
'status': 'no-indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': [],
|
||||
}
|
||||
payload = _jsonl_result(
|
||||
target='example.test',
|
||||
finding_type='takeover',
|
||||
value='outside.example.net',
|
||||
finding_fields={'actions': ['takeover'], 'details': details},
|
||||
summary_fields={
|
||||
'action_executions': [
|
||||
{
|
||||
'action': 'takeover',
|
||||
'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:
|
||||
response = client.post(
|
||||
'/api/v1/runs/import',
|
||||
params={'filename': 'out-of-scope-takeover.jsonl'},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'run target scope' in response.json()['detail']
|
||||
|
||||
|
||||
def test_api_rejects_takeover_indicator_without_same_scheme_http_evidence(tmp_path, monkeypatch) -> None:
|
||||
from theHarvester.lib.api import api
|
||||
|
||||
details = {
|
||||
'status': 'indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': ['bucket.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
],
|
||||
'http': [{'scheme': 'http', 'status': 404}],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/S3',
|
||||
'rule_id': 'aws-s3',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['body:BucketName'],
|
||||
}
|
||||
],
|
||||
'error_types': [],
|
||||
}
|
||||
payload = _jsonl_result(
|
||||
target='example.test',
|
||||
finding_type='takeover',
|
||||
value='bucket.example.test',
|
||||
finding_fields={'actions': ['takeover'], 'details': details},
|
||||
summary_fields={
|
||||
'action_executions': [
|
||||
{
|
||||
'action': 'takeover',
|
||||
'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:
|
||||
response = client.post(
|
||||
'/api/v1/runs/import',
|
||||
params={'filename': 'fabricated-takeover.jsonl'},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'successful outcome for their matching scheme' in response.json()['detail']
|
||||
|
||||
|
||||
def test_api_rejects_takeover_outcome_that_hides_nested_probe_failure(tmp_path, monkeypatch) -> None:
|
||||
from theHarvester.lib.api import api
|
||||
|
||||
details = {
|
||||
'status': 'no-indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
],
|
||||
'http': [{'scheme': 'https', 'error_type': 'TransportError'}],
|
||||
'indicators': [],
|
||||
'error_types': [],
|
||||
}
|
||||
payload = _jsonl_result(
|
||||
target='example.test',
|
||||
finding_type='takeover',
|
||||
value='bucket.example.test',
|
||||
finding_fields={'actions': ['takeover'], 'details': details},
|
||||
summary_fields={
|
||||
'action_executions': [
|
||||
{
|
||||
'action': 'takeover',
|
||||
'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:
|
||||
response = client.post(
|
||||
'/api/v1/runs/import',
|
||||
params={'filename': 'hidden-takeover-error.jsonl'},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'candidate errors' in response.json()['detail']
|
||||
|
||||
|
||||
def test_api_rejects_takeover_http_evidence_without_wildcard_controls(tmp_path, monkeypatch) -> None:
|
||||
from theHarvester.lib.api import api
|
||||
|
||||
details = {
|
||||
'status': 'no-indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': ['bucket.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
'indicators': [],
|
||||
'error_types': [],
|
||||
}
|
||||
payload = _jsonl_result(
|
||||
target='example.test',
|
||||
finding_type='takeover',
|
||||
value='bucket.example.test',
|
||||
finding_fields={'actions': ['takeover'], 'details': details},
|
||||
summary_fields={
|
||||
'action_executions': [
|
||||
{
|
||||
'action': 'takeover',
|
||||
'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:
|
||||
response = client.post(
|
||||
'/api/v1/runs/import',
|
||||
params={'filename': 'missing-takeover-control.jsonl'},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert 'wildcard control per resolver' in response.json()['detail']
|
||||
|
||||
|
||||
def test_api_rejects_partial_virtual_host_observation(tmp_path, monkeypatch) -> None:
|
||||
from theHarvester.lib.api import api
|
||||
|
||||
@@ -1312,6 +1537,49 @@ def test_api_schema_exposes_typed_shodan_host_details() -> None:
|
||||
assert tls['properties']['subject_alt_names']['items'] == {'type': 'string'}
|
||||
|
||||
|
||||
def test_api_schema_and_result_model_expose_typed_takeover_outcomes() -> None:
|
||||
from theHarvester.lib.api.run_models import NormalizedResult
|
||||
|
||||
details = {
|
||||
'status': 'inconclusive',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'ERROR',
|
||||
'error_type': 'DNSTimeoutError',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': ['DNSTimeoutError'],
|
||||
}
|
||||
|
||||
result = NormalizedResult(
|
||||
type='takeover',
|
||||
value='uncertain.example.test',
|
||||
actions=['takeover'],
|
||||
details=details,
|
||||
)
|
||||
|
||||
assert result.model_dump(exclude_none=True, exclude_defaults=True)['details'] == details
|
||||
schema = NormalizedResult.model_json_schema()
|
||||
assert {'$ref': '#/$defs/TakeoverDetailsResponse'} in schema['properties']['details']['anyOf']
|
||||
assert schema['$defs']['TakeoverDetailsResponse']['properties']['status']['enum'] == [
|
||||
'indicator',
|
||||
'no-indicator',
|
||||
'inconclusive',
|
||||
]
|
||||
with pytest.raises(ValueError, match='canonical structured details'):
|
||||
NormalizedResult(
|
||||
type='takeover',
|
||||
value='Uncertain.Example.test.',
|
||||
actions=['takeover'],
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def test_api_evidence_rejects_redundant_shodan_host_fields() -> None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
||||
@@ -531,6 +531,113 @@ async def test_shodan_host_evidence_round_trips_without_a_json_string_value(tmp_
|
||||
assert origins == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_evidence_round_trips_with_structured_details(tmp_path) -> None:
|
||||
from theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome
|
||||
|
||||
database = tmp_path / 'stash.sqlite'
|
||||
store = ResultStore(database)
|
||||
await store.initialize()
|
||||
collected_at = datetime(2026, 8, 15, 12, 2, tzinfo=UTC)
|
||||
outcome = TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
'status': 'indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': ['missing-bucket.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
],
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/S3',
|
||||
'rule_id': 'aws-s3',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['body:BucketName', 'body:The specified bucket does not exist'],
|
||||
}
|
||||
],
|
||||
'error_types': [],
|
||||
},
|
||||
)
|
||||
no_indicator = TakeoverCandidateOutcome.from_record(
|
||||
'live.example.test',
|
||||
{
|
||||
'status': 'no-indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': [],
|
||||
},
|
||||
)
|
||||
inconclusive = TakeoverCandidateOutcome.from_record(
|
||||
'uncertain.example.test',
|
||||
{
|
||||
'status': 'inconclusive',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'ERROR',
|
||||
'error_type': 'DNSTimeoutError',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': ['DNSTimeoutError'],
|
||||
},
|
||||
)
|
||||
outcomes = (outcome, no_indicator, inconclusive)
|
||||
result = CompletedResult.finish(
|
||||
target='example.test',
|
||||
started_at=collected_at,
|
||||
completed_at=collected_at,
|
||||
groups={},
|
||||
active_evidence=ActiveEvidence(
|
||||
executions=(
|
||||
ActionExecution.finish(
|
||||
action='takeover',
|
||||
status='completed',
|
||||
duration_ms=1,
|
||||
groups={'takeover': [item.hostname for item in outcomes]},
|
||||
),
|
||||
)
|
||||
),
|
||||
takeover_outcomes=outcomes,
|
||||
)
|
||||
|
||||
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 = ? ORDER BY value',
|
||||
(str(result.run_id),),
|
||||
).fetchall()
|
||||
assert stored == [
|
||||
('takeover', item.hostname, json.dumps(item.to_details(), separators=(',', ':'), sort_keys=True)) for item in outcomes
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_asn_organization_attribution_round_trips_in_a_normalized_table(tmp_path) -> None:
|
||||
database = tmp_path / 'stash.sqlite'
|
||||
|
||||
@@ -1159,6 +1159,154 @@ def test_completed_result_rejects_conflicting_shodan_hosts_for_one_ip() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_takeover_jsonl_uses_hostname_value_and_typed_evidence() -> None:
|
||||
from theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome
|
||||
|
||||
completed_at = datetime(2026, 8, 15, 12, 2, tzinfo=UTC)
|
||||
takeover_outcome = TakeoverCandidateOutcome.from_record(
|
||||
'bucket.example.test',
|
||||
{
|
||||
'status': 'indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': ['missing-bucket.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
],
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/S3',
|
||||
'rule_id': 'aws-s3',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['body:BucketName', 'body:The specified bucket does not exist'],
|
||||
}
|
||||
],
|
||||
'error_types': [],
|
||||
},
|
||||
)
|
||||
no_indicator = TakeoverCandidateOutcome.from_record(
|
||||
'live.example.test',
|
||||
{
|
||||
'status': 'no-indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': [],
|
||||
},
|
||||
)
|
||||
inconclusive = TakeoverCandidateOutcome.from_record(
|
||||
'uncertain.example.test',
|
||||
{
|
||||
'status': 'inconclusive',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'ERROR',
|
||||
'error_type': 'DNSTimeoutError',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': ['DNSTimeoutError'],
|
||||
},
|
||||
)
|
||||
result = CompletedResult.finish(
|
||||
target='example.test',
|
||||
started_at=completed_at,
|
||||
completed_at=completed_at,
|
||||
groups={},
|
||||
active_evidence=ActiveEvidence(
|
||||
executions=(
|
||||
ActionExecution.finish(
|
||||
action='takeover',
|
||||
status='completed',
|
||||
duration_ms=1,
|
||||
groups={'takeover': ['bucket.example.test', 'live.example.test', 'uncertain.example.test']},
|
||||
),
|
||||
)
|
||||
),
|
||||
takeover_outcomes=(takeover_outcome, no_indicator, inconclusive),
|
||||
)
|
||||
|
||||
records = [json.loads(line) for line in result.jsonl().splitlines()]
|
||||
|
||||
assert records[1:] == [
|
||||
{
|
||||
'actions': ['takeover'],
|
||||
'details': outcome.to_details(),
|
||||
'sources': [],
|
||||
'type': 'takeover',
|
||||
'value': outcome.hostname,
|
||||
}
|
||||
for outcome in (takeover_outcome, no_indicator, inconclusive)
|
||||
]
|
||||
summary, findings = parse_result_jsonl(result.jsonl())
|
||||
assert summary['result_count'] == 3
|
||||
assert findings == records[1:]
|
||||
|
||||
|
||||
def test_takeover_outcome_must_remain_inside_the_run_target_scope() -> None:
|
||||
from theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome
|
||||
|
||||
completed_at = datetime(2026, 8, 15, 12, 2, tzinfo=UTC)
|
||||
outcome = TakeoverCandidateOutcome.from_record(
|
||||
'outside.example.net',
|
||||
{
|
||||
'status': 'no-indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': [],
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='inside the run target scope'):
|
||||
CompletedResult.finish(
|
||||
target='example.test',
|
||||
started_at=completed_at,
|
||||
completed_at=completed_at,
|
||||
groups={},
|
||||
active_evidence=ActiveEvidence(
|
||||
executions=(
|
||||
ActionExecution.finish(
|
||||
action='takeover',
|
||||
status='completed',
|
||||
duration_ms=1,
|
||||
groups={'takeover': ['outside.example.net']},
|
||||
),
|
||||
)
|
||||
),
|
||||
takeover_outcomes=(outcome,),
|
||||
)
|
||||
|
||||
|
||||
def test_completed_result_rejects_artifact_without_a_real_subject_result() -> None:
|
||||
completed_at = datetime(2026, 8, 5, 12, 1, tzinfo=UTC)
|
||||
artifact = ArtifactReference(
|
||||
|
||||
+49
-86
@@ -273,6 +273,7 @@ def install_stream_response(
|
||||
) -> None:
|
||||
reset_dummy_sessions()
|
||||
monkeypatch.setattr(core_module.aiohttp, 'ClientSession', DummySession)
|
||||
monkeypatch.setattr(core_module.aiohttp, 'TCPConnector', lambda *, ssl=None: object())
|
||||
monkeypatch.setattr(core_module.ssl, 'create_default_context', lambda cafile=None: 'ssl-context')
|
||||
monkeypatch.setattr(core_module.certifi, 'where', lambda: '/tmp/cacert.pem')
|
||||
|
||||
@@ -809,6 +810,54 @@ async def test_fetch_json_reads_bounded_fragmented_utf8_without_redirects(monkey
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_text_preserves_non_success_metadata_without_redirects(monkeypatch) -> None:
|
||||
install_stream_response(
|
||||
monkeypatch,
|
||||
chunks=(b'not ', b'found'),
|
||||
status=404,
|
||||
headers={'Location': 'https://outside.example/path'},
|
||||
)
|
||||
|
||||
result = await AsyncFetcher.fetch_text(
|
||||
'https://app.example.test',
|
||||
request_timeout=None,
|
||||
response_byte_limit=32,
|
||||
)
|
||||
|
||||
assert result == FetcherResponse(
|
||||
body='not found',
|
||||
status=404,
|
||||
headers={'location': 'https://outside.example/path'},
|
||||
)
|
||||
assert DummySession.instances[0].closed is True
|
||||
assert DummySession.instances[0].requests[0][2]['allow_redirects'] is False
|
||||
assert isinstance(DummySession.instances[0].timeout, core_module.aiohttp.ClientTimeout)
|
||||
assert DummySession.instances[0].timeout.total is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_text_reuses_caller_owned_session_without_closing_it(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
install_stream_response(monkeypatch, chunks=(b'provider ', b'evidence'))
|
||||
session = DummySession(
|
||||
headers={'User-Agent': 'shared'},
|
||||
timeout=core_module.aiohttp.ClientTimeout(total=None),
|
||||
)
|
||||
|
||||
result = await AsyncFetcher.fetch_text(
|
||||
'https://app.example.test',
|
||||
session=session,
|
||||
request_timeout=None,
|
||||
response_byte_limit=32,
|
||||
)
|
||||
|
||||
assert result.body == 'provider evidence'
|
||||
assert session.closed is False
|
||||
assert len(DummySession.instances) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_json_accepts_body_at_shared_limit(monkeypatch) -> None:
|
||||
install_stream_response(monkeypatch, chunks=(b'{"a":1}',))
|
||||
@@ -975,92 +1024,6 @@ async def test_fetch_all_propagates_metadata_opt_in(monkeypatch) -> None:
|
||||
assert [result.status for result in results] == [429, 429]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('url', 'proxy', 'uses_shared_session'),
|
||||
[
|
||||
('http://100.64.0.1', None, True),
|
||||
('http://example.com', 'http://proxy.example:8080', True),
|
||||
('https://example.com', 'socks5://proxy.example:1080', False),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_fetch_uses_the_shared_transport(
|
||||
monkeypatch,
|
||||
url: str,
|
||||
proxy: str | None,
|
||||
uses_shared_session: bool,
|
||||
) -> None:
|
||||
calls = []
|
||||
session = DummySession()
|
||||
|
||||
async def fake_fetch(*_args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return 'response-text'
|
||||
|
||||
monkeypatch.setattr(AsyncFetcher, 'fetch', fake_fetch)
|
||||
|
||||
result = await AsyncFetcher.takeover_fetch(
|
||||
session,
|
||||
url,
|
||||
proxy=proxy,
|
||||
headers={'User-Agent': 'browser-agent'},
|
||||
)
|
||||
|
||||
assert result == (url, 'response-text')
|
||||
assert calls == [
|
||||
{
|
||||
'session': session if uses_shared_session else None,
|
||||
'url': url,
|
||||
'proxy': proxy,
|
||||
'headers': {'User-Agent': 'browser-agent'},
|
||||
'request_timeout': 15,
|
||||
'include_metadata': False,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_fetch_all_falls_back_to_direct_when_proxy_pool_is_empty(monkeypatch) -> None:
|
||||
reset_dummy_sessions()
|
||||
calls = []
|
||||
monkeypatch.setattr(core_module.aiohttp, 'ClientSession', DummySession)
|
||||
monkeypatch.setattr(AsyncFetcher, '_get_random_proxy', staticmethod(lambda _proxy_dict: (None, None)))
|
||||
|
||||
async def fake_takeover_fetch(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return 'http://example.com', 'direct response'
|
||||
|
||||
monkeypatch.setattr(AsyncFetcher, 'takeover_fetch', fake_takeover_fetch)
|
||||
|
||||
result = await AsyncFetcher.fetch_all(['http://example.com'], takeover=True, proxy=True)
|
||||
|
||||
assert result == [('http://example.com', 'direct response')]
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] == {
|
||||
'proxy': None,
|
||||
'headers': {'User-Agent': Core.get_user_agent()},
|
||||
'include_metadata': False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_fetch_all_propagates_metadata_opt_in(monkeypatch) -> None:
|
||||
reset_dummy_sessions()
|
||||
seen: list[bool] = []
|
||||
monkeypatch.setattr(core_module.aiohttp, 'ClientSession', DummySession)
|
||||
|
||||
async def fake_takeover_fetch(*_args, include_metadata: bool = False, **_kwargs):
|
||||
seen.append(include_metadata)
|
||||
return 'https://example.com', FetcherResponse(body='', status=204, headers={})
|
||||
|
||||
monkeypatch.setattr(AsyncFetcher, 'takeover_fetch', fake_takeover_fetch)
|
||||
|
||||
result = await AsyncFetcher.fetch_all(['https://example.com'], takeover=True, include_metadata=True)
|
||||
|
||||
assert seen == [True]
|
||||
assert result[0][1].status == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_uses_http_proxy_when_enabled(monkeypatch) -> None:
|
||||
reset_dummy_sessions()
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_enumeration_options_fill_the_shared_execution_defaults() -> None:
|
||||
assert options.start == 0
|
||||
assert options.dns_recursive_query_limit == DEFAULT_DNS_RECURSIVE_QUERY_LIMIT is None
|
||||
assert options.dns_recursive_runtime_seconds == DEFAULT_DNS_RECURSIVE_RUNTIME_SECONDS is None
|
||||
assert options.source_workers == DEFAULT_SOURCE_WORKERS == 6
|
||||
assert options.source_workers == DEFAULT_SOURCE_WORKERS == 3
|
||||
|
||||
|
||||
def test_enumeration_options_preserve_explicit_transport_values() -> None:
|
||||
|
||||
+130
-39
@@ -23,6 +23,7 @@ from theHarvester.lib.hostchecker import HostDnsRecords
|
||||
from theHarvester.lib.network_evidence import PrefixOriginObservation, RpkiValidationObservation
|
||||
from theHarvester.lib.recursive_dns import RecursiveDNSClassification, RecursiveDNSFinding, RecursiveDNSResult
|
||||
from theHarvester.lib.routeviews import RouteViewsCancelled, RouteViewsResult
|
||||
from theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome
|
||||
from theHarvester.lib.virtual_host import (
|
||||
HarvestedVirtualHostResult,
|
||||
VirtualHostDiscoveryCancelled,
|
||||
@@ -41,7 +42,10 @@ 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, Shodan, and takeover requests.' in help_text
|
||||
assert (
|
||||
'Use proxies.yaml for supported discovery-source, Shodan, and takeover requests. Takeover fails closed if no '
|
||||
'proxy is available.' 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 '
|
||||
@@ -62,6 +66,7 @@ async def test_cli_help_explains_proxy_and_direct_action_scope(
|
||||
assert 'Candidate names are never resolved through DNS.' in help_text
|
||||
assert '-j SOURCE_WORKERS' in help_text
|
||||
assert '--source-workers SOURCE_WORKERS' in help_text
|
||||
assert 'Indicators are not confirmed takeovers.' in help_text
|
||||
|
||||
|
||||
def _confirmed_vhost(endpoint: str = 'http://192.0.2.10:80/') -> VirtualHostObservation:
|
||||
@@ -98,6 +103,41 @@ def _confirmed_vhost(endpoint: str = 'http://192.0.2.10:80/') -> VirtualHostObse
|
||||
)
|
||||
|
||||
|
||||
def _takeover_outcome(hostname: str = 'api.example.com') -> TakeoverCandidateOutcome:
|
||||
return TakeoverCandidateOutcome.from_record(
|
||||
hostname,
|
||||
{
|
||||
'status': 'indicator',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': ['missing-bucket.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
],
|
||||
'http': [{'scheme': 'https', 'status': 404}],
|
||||
'indicators': [
|
||||
{
|
||||
'classification': 'vulnerable-indicator',
|
||||
'service': 'AWS/S3',
|
||||
'rule_id': 'aws-s3',
|
||||
'rule_revision': 'takeover-rules-v1',
|
||||
'scheme': 'https',
|
||||
'matched': ['body:BucketName', 'body:The specified bucket does not exist'],
|
||||
}
|
||||
],
|
||||
'error_types': [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_virtual_host_action_reaches_completed_evidence(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[dict[str, object]] = []
|
||||
@@ -3011,21 +3051,23 @@ async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pyte
|
||||
return ['api.example.com:192.0.2.10'], ['api.example.com'], ['192.0.2.10']
|
||||
|
||||
class FakeTakeOver:
|
||||
def __init__(self, hosts: list[str]) -> None:
|
||||
def __init__(self, hosts: list[str], *, target: str, nameservers: list[str]) -> None:
|
||||
assert hosts == ['api.example.com']
|
||||
assert target == 'example.com'
|
||||
assert nameservers == ['192.0.2.53']
|
||||
self.request_count = 2
|
||||
self.request_error_count = 0
|
||||
self.dns_error_count = 0
|
||||
self.completed_count = 1
|
||||
self.request_error_types: set[str] = set()
|
||||
self.scan_error_type = None
|
||||
|
||||
async def populate_fingerprints(self) -> None:
|
||||
return None
|
||||
self.stop_reason = None
|
||||
|
||||
async def process(self, proxy: bool = False) -> None:
|
||||
assert proxy is True
|
||||
|
||||
async def get_takeover_results(self) -> dict[str, list[dict[str, str]]]:
|
||||
return {'https://api.example.com': [{'No such app': 'Heroku'}]}
|
||||
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
||||
return (_takeover_outcome(),)
|
||||
|
||||
class FakeScreenShotter(_FakeScreenshotBatch):
|
||||
slash = '/'
|
||||
@@ -3122,7 +3164,7 @@ async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pyte
|
||||
monkeypatch.setattr(theharvester_main, 'ResultStore', FakeResultStore)
|
||||
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', FakeCrtsh)
|
||||
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', FakeChecker)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', FakeTakeOver)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', FakeTakeOver)
|
||||
monkeypatch.setattr(theharvester_main, 'ScreenShotter', FakeScreenShotter)
|
||||
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', FakeShodan)
|
||||
monkeypatch.setattr(theharvester_main.api_endpoints, 'SearchApiEndpoints', FakeApiScanner)
|
||||
@@ -3159,9 +3201,10 @@ async def test_direct_action_evidence_reaches_completed_result(monkeypatch: pyte
|
||||
assert completed.asn_attributions[0].organization_label == 'Example Transit'
|
||||
takeover_result = (
|
||||
'takeover',
|
||||
'{"matches":[{"No such app":"Heroku"}],"url":"https://api.example.com"}',
|
||||
'api.example.com',
|
||||
)
|
||||
assert takeover_result in completed.results
|
||||
assert completed.takeover_outcomes == (_takeover_outcome(),)
|
||||
takeover_execution = next(execution for execution in completed.active_evidence.executions if execution.action == 'takeover')
|
||||
assert takeover_execution.status == 'completed'
|
||||
assert takeover_execution.result_count == 1
|
||||
@@ -3270,11 +3313,20 @@ async def test_shodan_source_evidence_is_not_overwritten_by_conflicting_action_e
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('request_count', 'request_errors', 'scan_error', 'expected_status', 'expected_error', 'expected_reason'),
|
||||
(
|
||||
'request_count',
|
||||
'request_errors',
|
||||
'scan_error',
|
||||
'inconclusive_count',
|
||||
'expected_status',
|
||||
'expected_error',
|
||||
'expected_reason',
|
||||
),
|
||||
[
|
||||
(2, 1, None, 'partial', 'TransportError', 'request-errors'),
|
||||
(2, 2, None, 'failed', 'TransportError', 'request-errors'),
|
||||
(0, 0, 'RuntimeError', 'failed', 'RuntimeError', 'scan-error'),
|
||||
(2, 1, None, 0, 'partial', 'TransportError', 'request-errors'),
|
||||
(2, 2, None, 1, 'failed', 'TransportError', 'incomplete-candidates'),
|
||||
(0, 0, 'RuntimeError', 0, 'failed', 'RuntimeError', 'scan-error'),
|
||||
(0, 0, None, 1, 'failed', 'WildcardIndistinguishableError', 'wildcard-indistinguishable'),
|
||||
],
|
||||
)
|
||||
async def test_takeover_action_records_suppressed_outcome(
|
||||
@@ -3282,30 +3334,68 @@ async def test_takeover_action_records_suppressed_outcome(
|
||||
request_count: int,
|
||||
request_errors: int,
|
||||
scan_error: str | None,
|
||||
inconclusive_count: int,
|
||||
expected_status: str,
|
||||
expected_error: str,
|
||||
expected_error: str | None,
|
||||
expected_reason: str,
|
||||
) -> None:
|
||||
class FakeTakeOver:
|
||||
def __init__(self, _hosts: list[str]) -> None:
|
||||
def __init__(self, _hosts: list[str], **_kwargs: object) -> None:
|
||||
self.request_count = request_count
|
||||
self.request_error_count = request_errors
|
||||
self.request_error_types = {'TransportError'} if request_errors else set()
|
||||
self.dns_error_count = 0
|
||||
self.inconclusive_count = inconclusive_count
|
||||
self.candidate_count = 1
|
||||
self.completed_count = 0 if scan_error else 1
|
||||
self.request_error_types = (
|
||||
{'TransportError'} if request_errors else {'WildcardIndistinguishableError'} if inconclusive_count else set()
|
||||
)
|
||||
self.scan_error_type = scan_error
|
||||
|
||||
async def populate_fingerprints(self) -> None:
|
||||
return None
|
||||
if scan_error:
|
||||
self.stop_reason = 'scan-error'
|
||||
elif inconclusive_count:
|
||||
self.stop_reason = 'incomplete-candidates' if request_errors else 'wildcard-indistinguishable'
|
||||
else:
|
||||
self.stop_reason = None
|
||||
|
||||
async def process(self, proxy: bool = False) -> None:
|
||||
assert proxy is False
|
||||
return None
|
||||
|
||||
async def get_takeover_results(self) -> dict:
|
||||
return {}
|
||||
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
||||
if scan_error:
|
||||
return ()
|
||||
if inconclusive_count:
|
||||
return (
|
||||
TakeoverCandidateOutcome.from_record(
|
||||
'api.example.com',
|
||||
{
|
||||
'status': 'inconclusive',
|
||||
'dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': ['missing-bucket.s3.amazonaws.com'],
|
||||
'terminal_rcode': 'NOERROR',
|
||||
}
|
||||
],
|
||||
'wildcard_dns': [
|
||||
{
|
||||
'resolver': '1.1.1.1',
|
||||
'cname_chain': [],
|
||||
'terminal_rcode': 'NXDOMAIN',
|
||||
}
|
||||
],
|
||||
'http': [],
|
||||
'indicators': [],
|
||||
'error_types': ['TransportError'] if request_errors else ['WildcardIndistinguishableError'],
|
||||
},
|
||||
),
|
||||
)
|
||||
return (_takeover_outcome(),)
|
||||
|
||||
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
||||
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', _ApiHostSource)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', FakeTakeOver)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', FakeTakeOver)
|
||||
|
||||
result = await theharvester_main.start(
|
||||
EnumerationOptions(domain='example.com', quiet=True, source='crtsh', take_over=True),
|
||||
@@ -3314,7 +3404,7 @@ async def test_takeover_action_records_suppressed_outcome(
|
||||
|
||||
execution = next(item for item in result[-1].active_evidence.executions if item.action == 'takeover')
|
||||
assert execution.status == expected_status
|
||||
assert execution.result_count == 0
|
||||
assert execution.result_count == (0 if scan_error else 1)
|
||||
assert execution.error_type == expected_error
|
||||
assert execution.stop_reason == expected_reason
|
||||
|
||||
@@ -3596,11 +3686,11 @@ async def test_api_scan_reports_exhausted_retries_truthfully(
|
||||
@pytest.mark.asyncio
|
||||
async def test_takeover_without_hosts_is_skipped_without_starting(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class UnexpectedTakeOver:
|
||||
def __init__(self, _hosts: list[str]) -> None:
|
||||
def __init__(self, _hosts: list[str], **_kwargs: object) -> None:
|
||||
raise AssertionError('takeover should not start without hosts')
|
||||
|
||||
monkeypatch.setattr(theharvester_main, 'ResultStore', _NoopResultStore)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', UnexpectedTakeOver)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', UnexpectedTakeOver)
|
||||
|
||||
result = await theharvester_main.start(
|
||||
EnumerationOptions(domain='example.com', quiet=True, take_over=True),
|
||||
@@ -4037,28 +4127,29 @@ async def test_takeover_failure_persists_and_propagates(
|
||||
saved: list[CompletedResult] = []
|
||||
|
||||
class CancelledTakeOver:
|
||||
def __init__(self, _hosts: list[str]) -> None:
|
||||
def __init__(self, _hosts: list[str], **_kwargs: object) -> None:
|
||||
self.request_count = 0
|
||||
self.request_error_count = 0
|
||||
self.dns_error_count = 0
|
||||
self.completed_count = 0
|
||||
self.request_error_types: set[str] = set()
|
||||
self.scan_error_type = None
|
||||
self.stop_reason = None
|
||||
if failure_stage == 'init':
|
||||
raise raised_error
|
||||
|
||||
async def populate_fingerprints(self) -> None:
|
||||
return None
|
||||
|
||||
async def process(self, _proxy: bool = False, **_kwargs) -> None:
|
||||
if failure_stage == 'process':
|
||||
raise raised_error
|
||||
|
||||
async def get_takeover_results(self) -> dict:
|
||||
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
||||
if failure_stage == 'getter':
|
||||
raise raised_error
|
||||
return {}
|
||||
return ()
|
||||
|
||||
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
|
||||
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', _ApiHostSource)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', CancelledTakeOver)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', CancelledTakeOver)
|
||||
|
||||
with pytest.raises(type(raised_error)):
|
||||
await theharvester_main.start(
|
||||
@@ -4146,20 +4237,20 @@ async def test_direct_action_checkpoint_cancellation_persists_and_propagates(
|
||||
saved: list[CompletedResult] = []
|
||||
|
||||
class FakeTakeOver:
|
||||
def __init__(self, _hosts: list[str]) -> None:
|
||||
def __init__(self, _hosts: list[str], **_kwargs: object) -> None:
|
||||
self.request_count = 1
|
||||
self.request_error_count = 0
|
||||
self.dns_error_count = 0
|
||||
self.completed_count = 1
|
||||
self.request_error_types: set[str] = set()
|
||||
self.scan_error_type = None
|
||||
|
||||
async def populate_fingerprints(self) -> None:
|
||||
return None
|
||||
self.stop_reason = None
|
||||
|
||||
async def process(self, proxy: bool = False) -> None:
|
||||
assert proxy is False
|
||||
|
||||
async def get_takeover_results(self) -> dict:
|
||||
return {}
|
||||
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
||||
return ()
|
||||
|
||||
class FakeShodan:
|
||||
error_type = None
|
||||
@@ -4185,7 +4276,7 @@ async def test_direct_action_checkpoint_cancellation_persists_and_propagates(
|
||||
|
||||
monkeypatch.setattr(theharvester_main, 'ResultStore', _recording_result_store(saved))
|
||||
monkeypatch.setattr(source_runner.crtsh, 'SearchCrtsh', _ApiHostSource)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeOver', FakeTakeOver)
|
||||
monkeypatch.setattr(theharvester_main.takeover, 'TakeoverScanner', FakeTakeOver)
|
||||
monkeypatch.setattr(theharvester_main.hostchecker, 'Checker', _ApiHostChecker)
|
||||
monkeypatch.setattr(theharvester_main.shodansearch, 'SearchShodan', FakeShodan)
|
||||
|
||||
|
||||
@@ -161,7 +161,9 @@ def test_readme_explains_jsonl_record_and_structured_evidence_parsing() -> None:
|
||||
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`, and `takeover`' in readme
|
||||
assert '`person` and `infostealer`' in readme
|
||||
assert 'Takeover outcomes instead keep the canonical hostname in `value`' in readme
|
||||
assert 'typed status, DNS, wildcard, HTTP, rule, and error evidence in `details`' 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
|
||||
|
||||
+31
-21
@@ -9,7 +9,7 @@ import secrets
|
||||
import string
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Iterable, Mapping
|
||||
from collections.abc import Awaitable, Callable, Iterable
|
||||
from contextlib import AsyncExitStack
|
||||
from datetime import UTC, datetime
|
||||
from ipaddress import ip_address, ip_network
|
||||
@@ -85,6 +85,7 @@ from theHarvester.screenshot.screenshot import ScreenShotter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from theHarvester.lib.network_evidence import NetworkObservation
|
||||
from theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -179,7 +180,7 @@ async def start(
|
||||
parser.add_argument(
|
||||
'-p',
|
||||
'--proxies',
|
||||
help='Use proxies.yaml for supported discovery-source, Shodan, and takeover requests.',
|
||||
help='Use proxies.yaml for supported discovery-source, Shodan, and takeover requests. Takeover fails closed if no proxy is available.',
|
||||
default=False,
|
||||
action='store_true',
|
||||
)
|
||||
@@ -222,7 +223,11 @@ async def start(
|
||||
parser.add_argument(
|
||||
'-t',
|
||||
'--take-over',
|
||||
help='Check discovered hosts for known takeover indicators, using configured proxies when enabled.',
|
||||
help=(
|
||||
'Check discovered hosts for provider-gated takeover indicators. Uses configured DNS resolvers and '
|
||||
'wildcard controls, does not follow redirects, and uses configured proxies when enabled. Indicators '
|
||||
'are not confirmed takeovers.'
|
||||
),
|
||||
default=False,
|
||||
action='store_true',
|
||||
)
|
||||
@@ -534,7 +539,8 @@ async def start(
|
||||
screenshot_ip_addresses: set[str] = set()
|
||||
shodan_hosts: dict[str, ShodanHostObservation] = {}
|
||||
shodan_action_hosts: set[str] = set()
|
||||
takeover_results: dict[str, list[dict[str, str]]] = {}
|
||||
takeover_results: dict[str, dict[str, object]] = {}
|
||||
takeover_outcomes: list[TakeoverCandidateOutcome] = []
|
||||
linkedin_people_list_tracker = []
|
||||
twitter_people_list_tracker = []
|
||||
total_asns = []
|
||||
@@ -646,6 +652,7 @@ async def start(
|
||||
asn_attributions=asn_attributions,
|
||||
virtual_hosts=vhost_observations if collect_hosts else (),
|
||||
shodan_hosts=tuple(shodan_hosts.values()),
|
||||
takeover_outcomes=takeover_outcomes,
|
||||
)
|
||||
except (ValueError, TypeError) as error:
|
||||
output_logger.info(f'[!] An error occurred while completing the result: {error}')
|
||||
@@ -1373,28 +1380,26 @@ async def start(
|
||||
)
|
||||
)
|
||||
else:
|
||||
search_take: takeover.TakeOver | None = None
|
||||
search_take: takeover.TakeoverScanner | None = None
|
||||
|
||||
def normalize_takeover_evidence(results: Mapping[str, object]) -> set[str]:
|
||||
return {
|
||||
json.dumps({'matches': matches, 'url': url}, separators=(',', ':'), sort_keys=True)
|
||||
for url, matches in results.items()
|
||||
}
|
||||
|
||||
async def collect_takeover_evidence(*, best_effort: bool = False) -> tuple[dict[str, list[dict[str, str]]], set[str]]:
|
||||
async def collect_takeover_evidence(*, best_effort: bool = False) -> tuple[dict[str, dict[str, object]], set[str]]:
|
||||
if search_take is None:
|
||||
return {}, set()
|
||||
try:
|
||||
results = await search_take.get_takeover_results()
|
||||
outcomes = await search_take.get_takeover_outcomes()
|
||||
except (asyncio.CancelledError, Exception):
|
||||
if not best_effort:
|
||||
raise
|
||||
return {}, set()
|
||||
return results, normalize_takeover_evidence(results)
|
||||
takeover_outcomes[:] = list(outcomes)
|
||||
return ({outcome.hostname: outcome.to_details() for outcome in outcomes}, {item.hostname for item in outcomes})
|
||||
|
||||
try:
|
||||
search_take = takeover.TakeOver(all_hosts)
|
||||
await search_take.populate_fingerprints()
|
||||
search_take = takeover.TakeoverScanner(
|
||||
all_hosts,
|
||||
target=word,
|
||||
nameservers=final_dns_resolver_list or DEFAULT_DNS_RESOLVERS,
|
||||
)
|
||||
await search_take.process(proxy=use_proxy)
|
||||
takeover_results, takeover_evidence = await collect_takeover_evidence()
|
||||
except (asyncio.CancelledError, Exception) as error:
|
||||
@@ -1413,14 +1418,16 @@ async def start(
|
||||
raise
|
||||
assert search_take is not None
|
||||
takeover_request_errors = search_take.request_error_count
|
||||
takeover_dns_errors = search_take.dns_error_count
|
||||
takeover_inconclusive = getattr(search_take, 'inconclusive_count', 0)
|
||||
takeover_scan_error = search_take.scan_error_type
|
||||
takeover_status_value: ExecutionStatus = 'completed'
|
||||
if takeover_scan_error:
|
||||
takeover_status_value = 'partial' if takeover_evidence else 'failed'
|
||||
elif takeover_request_errors:
|
||||
takeover_status_value = (
|
||||
'partial' if takeover_evidence or takeover_request_errors < search_take.request_count else 'failed'
|
||||
)
|
||||
elif takeover_inconclusive:
|
||||
takeover_status_value = 'failed' if takeover_inconclusive == search_take.candidate_count else 'partial'
|
||||
elif takeover_request_errors or takeover_dns_errors:
|
||||
takeover_status_value = 'partial'
|
||||
action_executions.append(
|
||||
ActionExecution.finish(
|
||||
action='takeover',
|
||||
@@ -1428,7 +1435,10 @@ async def start(
|
||||
duration_ms=(time.perf_counter() - takeover_started) * 1000,
|
||||
groups={'takeover': takeover_evidence},
|
||||
error_type=takeover_scan_error or next(iter(sorted(search_take.request_error_types)), None),
|
||||
stop_reason=('scan-error' if takeover_scan_error else 'request-errors' if takeover_request_errors else None),
|
||||
stop_reason=(
|
||||
search_take.stop_reason
|
||||
or ('request-errors' if takeover_request_errors else 'query-errors' if takeover_dns_errors else None)
|
||||
),
|
||||
)
|
||||
)
|
||||
await checkpoint_action_result()
|
||||
|
||||
+591
-114
@@ -1,135 +1,612 @@
|
||||
import logging
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from random import shuffle
|
||||
import secrets
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import ujson
|
||||
import aiodns
|
||||
import aiohttp
|
||||
|
||||
from theHarvester.lib.core import AsyncFetcher, Core, FetcherResponse
|
||||
from theHarvester.lib.cancellation import drain_tasks_after_cancellation
|
||||
from theHarvester.lib.core import AsyncFetcher, Core, FetcherResponse, ResponseStreamError
|
||||
from theHarvester.lib.hostnames import normalize_hostname, normalize_scoped_hostname
|
||||
from theHarvester.lib.output import output_logger
|
||||
from theHarvester.lib.takeover_evidence import (
|
||||
HttpScheme,
|
||||
TakeoverCandidateOutcome,
|
||||
TakeoverCandidateStatus,
|
||||
TakeoverDNSOutcome,
|
||||
TakeoverHTTPOutcome,
|
||||
TakeoverIndicator,
|
||||
TakeoverRcode,
|
||||
canonical_takeover_outcomes,
|
||||
)
|
||||
from theHarvester.lib.takeover_rules import (
|
||||
TAKEOVER_RULE_REVISION,
|
||||
TAKEOVER_RULES,
|
||||
TakeoverRule,
|
||||
validate_takeover_rules,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
DEFAULT_TAKEOVER_CONCURRENCY = 20
|
||||
MAX_TAKEOVER_RESPONSE_BYTES = 1024 * 1024
|
||||
MAX_CNAME_HOPS = 32
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
class TakeOver:
|
||||
def __init__(self, hosts) -> None:
|
||||
# NOTE THIS MODULE IS ACTIVE RECON
|
||||
self.hosts = hosts
|
||||
self.proxy = False
|
||||
self.fingerprints: dict[str, str] = dict()
|
||||
# https://stackoverflow.com/questions/33080869/python-how-to-create-a-dict-of-dict-of-list-with-defaultdict
|
||||
self.results: defaultdict[str, list] = defaultdict(list)
|
||||
self.request_count = 0
|
||||
self.request_error_count = 0
|
||||
self.request_error_types: set[str] = set()
|
||||
self.scan_error_type: str | None = None
|
||||
class TakeoverDNSResolver:
|
||||
"""Resolve complete external CNAME relationships through one configured vantage."""
|
||||
|
||||
async def populate_fingerprints(self):
|
||||
# Thank you to https://github.com/EdOverflow/can-i-take-over-xyz for these fingerprints
|
||||
populate_url = 'https://raw.githubusercontent.com/EdOverflow/can-i-take-over-xyz/master/fingerprints.json'
|
||||
headers = {'User-Agent': Core.get_user_agent()}
|
||||
response = await AsyncFetcher.fetch_all([populate_url], headers=headers)
|
||||
try:
|
||||
resp = response[0]
|
||||
unparsed_json = ujson.loads(resp)
|
||||
for unparsed_fingerprint in unparsed_json:
|
||||
if unparsed_fingerprint['service'] == 'Smugsmug':
|
||||
# Subdomain must be in format domain.smugsmug.com
|
||||
# This will never happen as subdomains are parsed and filtered to be in format of *.word.com
|
||||
def __init__(self, nameserver: str) -> None:
|
||||
self.nameserver = str(ipaddress.ip_address(nameserver))
|
||||
self._resolver = aiodns.DNSResolver(nameservers=[self.nameserver])
|
||||
|
||||
async def query(self, hostname: str) -> TakeoverDNSOutcome:
|
||||
current = hostname
|
||||
chain: list[str] = []
|
||||
seen = {current}
|
||||
for _hop in range(MAX_CNAME_HOPS):
|
||||
results = await asyncio.gather(
|
||||
*(self._resolver.query_dns(current, record_type) for record_type in ('A', 'AAAA', 'CNAME')),
|
||||
return_exceptions=True,
|
||||
)
|
||||
next_cnames: list[str] = []
|
||||
dns_errors: list[int] = []
|
||||
error_types: set[str] = set()
|
||||
address_found = False
|
||||
for record_type, result in zip(('A', 'AAAA', 'CNAME'), results, strict=True):
|
||||
if isinstance(result, asyncio.CancelledError):
|
||||
raise result
|
||||
if isinstance(result, Exception):
|
||||
if isinstance(result, aiodns.error.DNSError) and result.args:
|
||||
error_code = int(result.args[0])
|
||||
dns_errors.append(error_code)
|
||||
if error_code not in {aiodns.error.ARES_ENOTFOUND, aiodns.error.ARES_ENODATA}:
|
||||
error_types.add(type(result).__name__)
|
||||
else:
|
||||
error_types.add(type(result).__name__)
|
||||
continue
|
||||
if unparsed_fingerprint['status'] == 'Vulnerable' or unparsed_fingerprint['status'] == 'Edge case':
|
||||
self.fingerprints[unparsed_fingerprint['fingerprint']] = unparsed_fingerprint['service']
|
||||
except Exception as e:
|
||||
logger.info(f'An exception has occurred populating takeover fingerprints: {e}, defaulting to static list')
|
||||
self.fingerprints = {
|
||||
"'Trying to access your account?'": 'Campaign Monitor',
|
||||
'404 Not Found': 'Fly.io',
|
||||
'404 error unknown site!': 'Pantheon',
|
||||
'Do you want to register *.wordpress.com?': 'Wordpress',
|
||||
'Domain uses DO name serves with no records in DO.': 'Digital Ocean',
|
||||
"It looks like you may have taken a wrong turn somewhere. Don't worry...it happens to all of us.": 'LaunchRock',
|
||||
'No Site For Domain': 'Kinsta',
|
||||
'No settings were found for this company:': 'Help Scout',
|
||||
'Project doesnt exist... yet!': 'Readme.io',
|
||||
'Repository not found': 'Bitbucket',
|
||||
'The feed has not been found.': 'Feedpress',
|
||||
'No such app': 'Heroku',
|
||||
'The specified bucket does not exist': 'AWS/S3',
|
||||
'The thing you were looking for is no longer here, or never was': 'Ghost',
|
||||
"There isn't a Github Pages site here.": 'Github',
|
||||
'This UserVoice subdomain is currently available!': 'UserVoice',
|
||||
"Uh oh. That page doesn't exist.": 'Intercom',
|
||||
"We could not find what you're looking for.": 'Help Juice',
|
||||
"Whatever you were looking for doesn't currently exist at this address": 'Tumblr',
|
||||
'is not a registered InCloud YouTrack': 'JetBrains',
|
||||
'page not found': 'Uptimerobot',
|
||||
'project not found': 'Surge.sh',
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
for record in result.answer:
|
||||
if record_type == 'CNAME':
|
||||
value = getattr(record.data, 'cname', None)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
next_cnames.append(normalize_hostname(value))
|
||||
except ValueError:
|
||||
error_types.add('InvalidCNAMEError')
|
||||
continue
|
||||
value = getattr(record.data, 'addr', None)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
error_types.add('InvalidAddressError')
|
||||
else:
|
||||
address_found = True
|
||||
next_cnames = list(dict.fromkeys(next_cnames))
|
||||
if next_cnames:
|
||||
for cname in next_cnames:
|
||||
if cname not in chain:
|
||||
chain.append(cname)
|
||||
current = next_cnames[-1]
|
||||
if current in seen:
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=tuple(chain),
|
||||
terminal_rcode='ERROR',
|
||||
error_type='CNAMECycleError',
|
||||
)
|
||||
seen.add(current)
|
||||
continue
|
||||
terminal_rcode: TakeoverRcode
|
||||
if address_found:
|
||||
terminal_rcode = 'NOERROR'
|
||||
elif dns_errors and all(code == aiodns.error.ARES_ENOTFOUND for code in dns_errors):
|
||||
terminal_rcode = 'NXDOMAIN'
|
||||
elif dns_errors and all(code in {aiodns.error.ARES_ENOTFOUND, aiodns.error.ARES_ENODATA} for code in dns_errors):
|
||||
terminal_rcode = 'NODATA'
|
||||
else:
|
||||
terminal_rcode = 'ERROR'
|
||||
error_type = next(iter(sorted(error_types)), None)
|
||||
if terminal_rcode == 'ERROR' and error_type is None:
|
||||
error_type = 'DNSError'
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=tuple(chain),
|
||||
terminal_rcode=terminal_rcode,
|
||||
error_type=error_type,
|
||||
)
|
||||
return TakeoverDNSOutcome(
|
||||
resolver=self.nameserver,
|
||||
cname_chain=tuple(chain),
|
||||
terminal_rcode='ERROR',
|
||||
error_type='CNAMEHopLimitError',
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._resolver.close()
|
||||
|
||||
|
||||
def _candidate_hostname(value: object, target: str) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
candidate = value.strip()
|
||||
if candidate.count(':') == 1:
|
||||
hostname, address = candidate.split(':', 1)
|
||||
try:
|
||||
ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
candidate = hostname
|
||||
scoped = normalize_scoped_hostname(candidate, target)
|
||||
if scoped is None:
|
||||
return None
|
||||
try:
|
||||
return normalize_hostname(scoped)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _rule_matches_dns(rule: TakeoverRule, outcome: TakeoverDNSOutcome) -> bool:
|
||||
return any(re.search(pattern, cname, flags=re.IGNORECASE) for pattern in rule.cname_patterns for cname in outcome.cname_chain)
|
||||
|
||||
|
||||
def _wildcard_probe_hostname(hostname: str, target: str) -> str:
|
||||
suffix = hostname.partition('.')[2] if hostname != target else target
|
||||
if suffix != target and not suffix.endswith(f'.{target}'):
|
||||
raise ValueError('takeover wildcard control must remain inside the authorized target')
|
||||
return f'takeover-control-{secrets.token_hex(12)}.{suffix}'
|
||||
|
||||
|
||||
def _dns_outcomes_disagree(outcomes: tuple[TakeoverDNSOutcome, ...]) -> bool:
|
||||
return len({(outcome.cname_chain, outcome.terminal_rcode) for outcome in outcomes}) > 1
|
||||
|
||||
|
||||
def _dns_outcome_errors(outcomes: tuple[TakeoverDNSOutcome, ...]) -> set[str]:
|
||||
return {
|
||||
outcome.error_type or 'DNSError'
|
||||
for outcome in outcomes
|
||||
if outcome.error_type is not None or outcome.terminal_rcode == 'ERROR'
|
||||
}
|
||||
|
||||
async def check(self, url, resp) -> None:
|
||||
# Simple function that takes response and checks if any fingerprints exist
|
||||
# If a fingerprint exists figures out which one and prints it out
|
||||
regex = re.compile('(?=(' + '|'.join(map(re.escape, list(self.fingerprints.keys()))) + '))')
|
||||
# Sanitize fingerprints
|
||||
matches = re.findall(regex, resp)
|
||||
matches = list(set(matches))
|
||||
for match in matches:
|
||||
output_logger.info(f'\t Takeover detected: {url}')
|
||||
if match in self.fingerprints.keys():
|
||||
# Validation check as to not error out
|
||||
service = self.fingerprints[match]
|
||||
output_logger.info(f'\t Type of takeover is: {service} with match: {match}')
|
||||
self.results[url].append({match: service})
|
||||
|
||||
async def do_take(self) -> None:
|
||||
def _rule_has_resolver_disagreement(rule: TakeoverRule, outcomes: tuple[TakeoverDNSOutcome, ...]) -> bool:
|
||||
matches = [_rule_matches_dns(rule, outcome) for outcome in outcomes]
|
||||
return any(matches) and not all(matches)
|
||||
|
||||
|
||||
def _wildcard_rule_state(
|
||||
rule: TakeoverRule,
|
||||
candidates: tuple[TakeoverDNSOutcome, ...],
|
||||
controls: tuple[TakeoverDNSOutcome, ...],
|
||||
) -> str:
|
||||
candidate_by_resolver = {outcome.resolver: outcome for outcome in candidates}
|
||||
control_by_resolver = {outcome.resolver: outcome for outcome in controls}
|
||||
equivalent: list[bool] = []
|
||||
for resolver, candidate in candidate_by_resolver.items():
|
||||
control = control_by_resolver.get(resolver)
|
||||
equivalent.append(
|
||||
control is not None
|
||||
and _rule_matches_dns(rule, candidate)
|
||||
and _rule_matches_dns(rule, control)
|
||||
and candidate.cname_chain == control.cname_chain
|
||||
and candidate.terminal_rcode == control.terminal_rcode
|
||||
)
|
||||
if equivalent and all(equivalent):
|
||||
return 'indistinguishable'
|
||||
if any(equivalent):
|
||||
return 'disagreement'
|
||||
return 'distinct'
|
||||
|
||||
|
||||
def _match_http(rule: TakeoverRule, response: FetcherResponse) -> tuple[str, ...]:
|
||||
body = str(response.body)
|
||||
headers = '\n'.join(f'{name}: {value}' for name, value in sorted(response.headers.items()))
|
||||
body_folded = body.casefold()
|
||||
headers_folded = headers.casefold()
|
||||
if rule.status_codes and response.status not in rule.status_codes:
|
||||
return ()
|
||||
if any(pattern.casefold() not in body_folded for pattern in rule.body_all):
|
||||
return ()
|
||||
if rule.body_any and not any(pattern.casefold() in body_folded for pattern in rule.body_any):
|
||||
return ()
|
||||
if any(pattern.casefold() in body_folded for pattern in rule.body_none):
|
||||
return ()
|
||||
if any(re.search(pattern, body, flags=re.IGNORECASE) is None for pattern in rule.body_regex_all):
|
||||
return ()
|
||||
if rule.body_regex_any and not any(re.search(pattern, body, flags=re.IGNORECASE) for pattern in rule.body_regex_any):
|
||||
return ()
|
||||
if any(re.search(pattern, body, flags=re.IGNORECASE) for pattern in rule.body_regex_none):
|
||||
return ()
|
||||
if rule.header_any and not any(pattern.casefold() in headers_folded for pattern in rule.header_any):
|
||||
return ()
|
||||
if any(pattern.casefold() in headers_folded for pattern in rule.header_none):
|
||||
return ()
|
||||
matched = [f'status:{response.status}' for _ in rule.status_codes]
|
||||
matched.extend(f'body:{pattern}' for pattern in rule.body_all)
|
||||
matched.extend(f'body:{pattern}' for pattern in rule.body_any if pattern.casefold() in body_folded)
|
||||
matched.extend(f'body-regex:{pattern}' for pattern in rule.body_regex_all)
|
||||
matched.extend(f'body-regex:{pattern}' for pattern in rule.body_regex_any if re.search(pattern, body, flags=re.IGNORECASE))
|
||||
matched.extend(f'header:{pattern}' for pattern in rule.header_any if pattern.casefold() in headers_folded)
|
||||
return tuple(sorted(set(matched)))
|
||||
|
||||
|
||||
class TakeoverScanner:
|
||||
"""Collect provider-gated takeover indicators without claiming provider resources."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hosts: Iterable[object],
|
||||
*,
|
||||
target: str,
|
||||
nameservers: Iterable[str],
|
||||
concurrency: int = DEFAULT_TAKEOVER_CONCURRENCY,
|
||||
rules: tuple[TakeoverRule, ...] = TAKEOVER_RULES,
|
||||
) -> None:
|
||||
if isinstance(concurrency, bool) or not isinstance(concurrency, int) or concurrency <= 0:
|
||||
raise ValueError('takeover concurrency must be greater than zero')
|
||||
self.target = normalize_hostname(target)
|
||||
self.hosts = tuple(
|
||||
sorted({hostname for value in hosts if (hostname := _candidate_hostname(value, self.target)) is not None})
|
||||
)
|
||||
self.nameservers = tuple(sorted({str(ipaddress.ip_address(item)) for item in nameservers}))
|
||||
if not self.nameservers:
|
||||
raise ValueError('takeover checks require at least one resolver')
|
||||
self.concurrency = concurrency
|
||||
self.rules = validate_takeover_rules(rules)
|
||||
self.candidate_count = len(self.hosts)
|
||||
self.completed_count = 0
|
||||
self.request_count = 0
|
||||
self.request_error_count = 0
|
||||
self.request_error_types.clear()
|
||||
self.scan_error_type = None
|
||||
self.dns_error_count = 0
|
||||
self.wildcard_indistinguishable_count = 0
|
||||
self.indicator_count = 0
|
||||
self.no_indicator_count = 0
|
||||
self.inconclusive_count = 0
|
||||
self.request_error_types: set[str] = set()
|
||||
self.scan_error_type: str | None = None
|
||||
self.stop_reason: str | None = None
|
||||
self._outcomes: list[TakeoverCandidateOutcome] = []
|
||||
self._proxy: str | None = None
|
||||
|
||||
async def _query_dns(
|
||||
self,
|
||||
hostname: str,
|
||||
resolvers: tuple[TakeoverDNSResolver, ...],
|
||||
) -> tuple[TakeoverDNSOutcome, ...]:
|
||||
outcomes: list[TakeoverDNSOutcome | None] = [None] * len(resolvers)
|
||||
|
||||
async def query(index: int, resolver: TakeoverDNSResolver) -> None:
|
||||
try:
|
||||
if len(self.hosts) > 0:
|
||||
# Returns a list of tuples in this format: (url, response)
|
||||
# Filter out responses whose responses are empty strings (indicates errored)
|
||||
https_hosts = [f'https://{host}' for host in self.hosts]
|
||||
http_hosts = [f'http://{host}' for host in self.hosts]
|
||||
all_hosts = https_hosts + http_hosts
|
||||
self.request_count = len(all_hosts)
|
||||
shuffle(all_hosts)
|
||||
responses: list[tuple[str, FetcherResponse | None]] = await AsyncFetcher.fetch_all(
|
||||
all_hosts,
|
||||
headers={'User-Agent': Core.get_browser_user_agent()},
|
||||
takeover=True,
|
||||
proxy=self.proxy,
|
||||
include_metadata=True,
|
||||
outcomes[index] = await resolver.query(hostname)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as error:
|
||||
outcomes[index] = TakeoverDNSOutcome(
|
||||
resolver=resolver.nameserver,
|
||||
cname_chain=(),
|
||||
terminal_rcode='ERROR',
|
||||
error_type=type(error).__name__,
|
||||
)
|
||||
for url, response in responses:
|
||||
if response is None:
|
||||
|
||||
async with asyncio.TaskGroup() as group:
|
||||
for index, resolver in enumerate(resolvers):
|
||||
group.create_task(query(index, resolver), name=f'takeover-dns:{hostname}:{resolver.nameserver}')
|
||||
completed = tuple(
|
||||
sorted(
|
||||
(outcome for outcome in outcomes if outcome is not None),
|
||||
key=TakeoverDNSOutcome.sort_key,
|
||||
)
|
||||
)
|
||||
self.dns_error_count += sum(item.terminal_rcode == 'ERROR' or item.error_type is not None for item in completed)
|
||||
self.request_error_types.update(item.error_type for item in completed if item.error_type is not None)
|
||||
return completed
|
||||
|
||||
async def _fetch_http(
|
||||
self,
|
||||
hostname: str,
|
||||
scheme: HttpScheme,
|
||||
session: aiohttp.ClientSession,
|
||||
) -> tuple[TakeoverHTTPOutcome, FetcherResponse | None]:
|
||||
self.request_count += 1
|
||||
try:
|
||||
response = await AsyncFetcher.fetch_text(
|
||||
f'{scheme}://{hostname}',
|
||||
session=session,
|
||||
proxy=self._proxy or '',
|
||||
follow_redirects=False,
|
||||
request_timeout=None,
|
||||
response_byte_limit=MAX_TAKEOVER_RESPONSE_BYTES,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except ResponseStreamError as error:
|
||||
error_type = 'ResponseLimitError' if error.reason == 'response-limit' else 'TransportError'
|
||||
self.request_error_count += 1
|
||||
self.request_error_types.add('TransportError')
|
||||
continue
|
||||
if response.body:
|
||||
await self.check(url, response.body)
|
||||
else:
|
||||
self.request_error_types.add(error_type)
|
||||
return (
|
||||
TakeoverHTTPOutcome(
|
||||
scheme=scheme,
|
||||
status=error.status,
|
||||
location=error.headers.get('location'),
|
||||
error_type=error_type,
|
||||
body_truncated=error.reason == 'response-limit',
|
||||
),
|
||||
None,
|
||||
)
|
||||
return (
|
||||
TakeoverHTTPOutcome(
|
||||
scheme=scheme,
|
||||
status=response.status,
|
||||
location=response.headers.get('location'),
|
||||
),
|
||||
response,
|
||||
)
|
||||
|
||||
async def _scan_candidate(
|
||||
self,
|
||||
hostname: str,
|
||||
resolvers: tuple[TakeoverDNSResolver, ...],
|
||||
session: aiohttp.ClientSession,
|
||||
) -> None:
|
||||
dns_outcomes = await self._query_dns(hostname, resolvers)
|
||||
error_types = _dns_outcome_errors(dns_outcomes)
|
||||
if error_types:
|
||||
self._record_outcome(
|
||||
TakeoverCandidateOutcome(
|
||||
hostname=hostname,
|
||||
status='inconclusive',
|
||||
dns=dns_outcomes,
|
||||
error_types=tuple(sorted(error_types)),
|
||||
)
|
||||
)
|
||||
return
|
||||
except IndexError:
|
||||
self.scan_error_type = 'IndexError'
|
||||
logger.info('Response was empty: possible network error or invalid URL.')
|
||||
except ujson.JSONDecodeError:
|
||||
self.scan_error_type = 'JSONDecodeError'
|
||||
logger.info('Failed to parse JSON: cert fingerprints might be unavailable.')
|
||||
except KeyError as ke:
|
||||
self.scan_error_type = 'KeyError'
|
||||
logger.info(f'Missing expected field in fingerprint: {ke}')
|
||||
except TypeError as te:
|
||||
self.scan_error_type = 'TypeError'
|
||||
logger.info(f'Invalid response structure: {te}')
|
||||
except Exception as e:
|
||||
self.scan_error_type = type(e).__name__
|
||||
logger.info(f'Unexpected error: {e}')
|
||||
candidate_rules = tuple(rule for rule in self.rules if all(_rule_matches_dns(rule, item) for item in dns_outcomes))
|
||||
if _dns_outcomes_disagree(dns_outcomes) or any(
|
||||
_rule_has_resolver_disagreement(rule, dns_outcomes) for rule in self.rules
|
||||
):
|
||||
self._record_outcome(
|
||||
TakeoverCandidateOutcome(
|
||||
hostname=hostname,
|
||||
status='inconclusive',
|
||||
dns=dns_outcomes,
|
||||
error_types=('ResolverDisagreementError',),
|
||||
)
|
||||
)
|
||||
return
|
||||
if not candidate_rules:
|
||||
self._record_outcome(TakeoverCandidateOutcome(hostname=hostname, status='no-indicator', dns=dns_outcomes))
|
||||
return
|
||||
wildcard_outcomes = await self._query_dns(_wildcard_probe_hostname(hostname, self.target), resolvers)
|
||||
wildcard_errors = _dns_outcome_errors(wildcard_outcomes)
|
||||
if _dns_outcomes_disagree(wildcard_outcomes):
|
||||
wildcard_errors.add('WildcardDisagreementError')
|
||||
if wildcard_errors:
|
||||
wildcard_errors.add('WildcardControlError')
|
||||
self._record_outcome(
|
||||
TakeoverCandidateOutcome(
|
||||
hostname=hostname,
|
||||
status='inconclusive',
|
||||
dns=dns_outcomes,
|
||||
wildcard_dns=wildcard_outcomes,
|
||||
error_types=tuple(sorted(wildcard_errors)),
|
||||
)
|
||||
)
|
||||
return
|
||||
retained_rules: list[TakeoverRule] = []
|
||||
for rule in candidate_rules:
|
||||
wildcard_state = _wildcard_rule_state(rule, dns_outcomes, wildcard_outcomes)
|
||||
if wildcard_state == 'distinct':
|
||||
retained_rules.append(rule)
|
||||
continue
|
||||
error_types.add(
|
||||
'WildcardIndistinguishableError' if wildcard_state == 'indistinguishable' else 'WildcardDisagreementError'
|
||||
)
|
||||
if not retained_rules:
|
||||
self.wildcard_indistinguishable_count += 1
|
||||
self.stop_reason = 'wildcard-indistinguishable'
|
||||
self._record_outcome(
|
||||
TakeoverCandidateOutcome(
|
||||
hostname=hostname,
|
||||
status='inconclusive',
|
||||
dns=dns_outcomes,
|
||||
wildcard_dns=wildcard_outcomes,
|
||||
error_types=tuple(sorted(error_types)),
|
||||
)
|
||||
)
|
||||
return
|
||||
indicators: list[TakeoverIndicator] = []
|
||||
dns_rules = tuple(rule for rule in retained_rules if rule.terminal_rcodes)
|
||||
for rule in dns_rules:
|
||||
matching_terminal = [outcome.terminal_rcode in rule.terminal_rcodes for outcome in dns_outcomes]
|
||||
if all(matching_terminal):
|
||||
indicators.append(
|
||||
TakeoverIndicator(
|
||||
classification=rule.classification,
|
||||
service=rule.service,
|
||||
rule_id=rule.rule_id,
|
||||
rule_revision=TAKEOVER_RULE_REVISION,
|
||||
matched=tuple(sorted(f'dns:terminal-rcode={rcode}' for rcode in rule.terminal_rcodes)),
|
||||
)
|
||||
)
|
||||
elif any(matching_terminal):
|
||||
error_types.add('ResolverDisagreementError')
|
||||
http_rules = tuple(rule for rule in retained_rules if not rule.terminal_rcodes)
|
||||
if not http_rules:
|
||||
self._record_evaluated_outcome(hostname, dns_outcomes, wildcard_outcomes, (), indicators, error_types)
|
||||
return
|
||||
fetched: list[tuple[TakeoverHTTPOutcome, FetcherResponse | None] | None] = [None, None]
|
||||
|
||||
async def fetch(index: int, scheme: HttpScheme) -> None:
|
||||
fetched[index] = await self._fetch_http(hostname, scheme, session)
|
||||
|
||||
schemes: tuple[HttpScheme, ...] = ('https', 'http')
|
||||
async with asyncio.TaskGroup() as group:
|
||||
for index, scheme in enumerate(schemes):
|
||||
group.create_task(fetch(index, scheme), name=f'takeover-http:{hostname}:{scheme}')
|
||||
http_outcomes = tuple(
|
||||
sorted(
|
||||
(item[0] for item in fetched if item is not None),
|
||||
key=TakeoverHTTPOutcome.sort_key,
|
||||
)
|
||||
)
|
||||
for rule in http_rules:
|
||||
for item in fetched:
|
||||
if item is None or item[1] is None:
|
||||
continue
|
||||
matched = _match_http(rule, item[1])
|
||||
if not matched:
|
||||
continue
|
||||
indicators.append(
|
||||
TakeoverIndicator(
|
||||
classification=rule.classification,
|
||||
service=rule.service,
|
||||
rule_id=rule.rule_id,
|
||||
rule_revision=TAKEOVER_RULE_REVISION,
|
||||
scheme=item[0].scheme,
|
||||
matched=matched,
|
||||
)
|
||||
)
|
||||
break
|
||||
error_types.update(item.error_type for item in http_outcomes if item.error_type is not None)
|
||||
self._record_evaluated_outcome(
|
||||
hostname,
|
||||
dns_outcomes,
|
||||
wildcard_outcomes,
|
||||
http_outcomes,
|
||||
indicators,
|
||||
error_types,
|
||||
)
|
||||
|
||||
def _record_evaluated_outcome(
|
||||
self,
|
||||
hostname: str,
|
||||
dns: tuple[TakeoverDNSOutcome, ...],
|
||||
wildcard_dns: tuple[TakeoverDNSOutcome, ...],
|
||||
http: tuple[TakeoverHTTPOutcome, ...],
|
||||
indicators: list[TakeoverIndicator],
|
||||
error_types: set[str],
|
||||
) -> None:
|
||||
canonical_indicators = tuple(sorted(set(indicators), key=TakeoverIndicator.sort_key))
|
||||
status: TakeoverCandidateStatus = (
|
||||
'indicator' if canonical_indicators else 'inconclusive' if error_types else 'no-indicator'
|
||||
)
|
||||
self._record_outcome(
|
||||
TakeoverCandidateOutcome(
|
||||
hostname=hostname,
|
||||
status=status,
|
||||
dns=dns,
|
||||
wildcard_dns=wildcard_dns,
|
||||
http=http,
|
||||
indicators=canonical_indicators,
|
||||
error_types=tuple(sorted(error_types)),
|
||||
)
|
||||
)
|
||||
|
||||
def _record_outcome(self, outcome: TakeoverCandidateOutcome) -> None:
|
||||
self._outcomes.append(outcome)
|
||||
self.request_error_types.update(outcome.error_types)
|
||||
if outcome.status == 'indicator':
|
||||
self.indicator_count += 1
|
||||
for indicator in outcome.indicators:
|
||||
output_logger.info(
|
||||
f'\t Takeover {indicator.classification}: {outcome.hostname} ({indicator.service}, rule {indicator.rule_id})'
|
||||
)
|
||||
elif outcome.status == 'no-indicator':
|
||||
self.no_indicator_count += 1
|
||||
else:
|
||||
self.inconclusive_count += 1
|
||||
output_logger.info(f'\t Takeover check inconclusive for {outcome.hostname}: {", ".join(outcome.error_types)}')
|
||||
|
||||
async def process(self, proxy: bool = False) -> None:
|
||||
self.proxy = proxy
|
||||
await self.do_take()
|
||||
self.completed_count = 0
|
||||
self.request_count = 0
|
||||
self.request_error_count = 0
|
||||
self.dns_error_count = 0
|
||||
self.wildcard_indistinguishable_count = 0
|
||||
self.indicator_count = 0
|
||||
self.no_indicator_count = 0
|
||||
self.inconclusive_count = 0
|
||||
self.request_error_types.clear()
|
||||
self.scan_error_type = None
|
||||
self.stop_reason = None
|
||||
self._outcomes.clear()
|
||||
self._proxy = None
|
||||
if proxy:
|
||||
proxy_url, _proxy_type = AsyncFetcher._resolve_proxy(True)
|
||||
if proxy_url is None:
|
||||
self.scan_error_type = 'ProxyUnavailableError'
|
||||
self.stop_reason = 'proxy-unavailable'
|
||||
return
|
||||
self._proxy = proxy_url
|
||||
resolvers: tuple[TakeoverDNSResolver, ...] = ()
|
||||
session: aiohttp.ClientSession | None = None
|
||||
candidates = iter(self.hosts)
|
||||
cancellation: asyncio.CancelledError | None = None
|
||||
phase_error: Exception | None = None
|
||||
|
||||
async def get_takeover_results(self):
|
||||
return self.results
|
||||
async def worker() -> None:
|
||||
for hostname in candidates:
|
||||
try:
|
||||
assert session is not None
|
||||
await self._scan_candidate(hostname, resolvers, session)
|
||||
finally:
|
||||
self.completed_count += 1
|
||||
|
||||
try:
|
||||
ssl_context = AsyncFetcher._ssl_context()
|
||||
proxy_url, proxy_type = AsyncFetcher._resolve_proxy(self._proxy or '')
|
||||
session = await AsyncFetcher._build_session(
|
||||
{'User-Agent': Core.get_browser_user_agent()},
|
||||
aiohttp.ClientTimeout(total=None),
|
||||
proxy_url,
|
||||
proxy_type,
|
||||
ssl_context,
|
||||
cookie_jar=aiohttp.DummyCookieJar(),
|
||||
)
|
||||
resolvers = tuple(TakeoverDNSResolver(nameserver) for nameserver in self.nameservers)
|
||||
async with asyncio.TaskGroup() as group:
|
||||
for index in range(min(self.concurrency, self.candidate_count)):
|
||||
group.create_task(worker(), name=f'takeover-worker:{index}')
|
||||
except asyncio.CancelledError as error:
|
||||
self.scan_error_type = 'CancelledError'
|
||||
self.stop_reason = 'cancelled'
|
||||
cancellation = error
|
||||
except Exception as error:
|
||||
phase_error = error
|
||||
self.scan_error_type = type(error).__name__
|
||||
self.stop_reason = 'scan-error'
|
||||
|
||||
close_tasks = [
|
||||
asyncio.create_task(resolver.close(), name=f'takeover-resolver-close:{resolver.nameserver}') for resolver in resolvers
|
||||
]
|
||||
if session is not None:
|
||||
close_tasks.append(asyncio.create_task(session.close(), name='takeover-http-session-close'))
|
||||
interruptions = await drain_tasks_after_cancellation(close_tasks, cancel=False)
|
||||
close_errors = tuple(task for task in close_tasks if not task.cancelled() and task.exception() is not None)
|
||||
if cancellation is not None:
|
||||
raise cancellation
|
||||
if interruptions:
|
||||
raise interruptions[0]
|
||||
if phase_error is not None:
|
||||
return
|
||||
if close_errors:
|
||||
close_task = close_errors[0]
|
||||
close_error = close_task.exception()
|
||||
assert close_error is not None
|
||||
self.scan_error_type = type(close_error).__name__
|
||||
self.stop_reason = (
|
||||
'http-session-close-error' if close_task.get_name() == 'takeover-http-session-close' else 'resolver-close-error'
|
||||
)
|
||||
elif self.inconclusive_count and self.stop_reason is None:
|
||||
self.stop_reason = 'incomplete-candidates'
|
||||
|
||||
async def get_takeover_outcomes(self) -> tuple[TakeoverCandidateOutcome, ...]:
|
||||
return canonical_takeover_outcomes(self._outcomes)
|
||||
|
||||
@@ -16,6 +16,7 @@ from theHarvester.lib.network_evidence import (
|
||||
)
|
||||
from theHarvester.lib.result_values import normalize_prefix
|
||||
from theHarvester.lib.shodan_evidence import ShodanHostObservation
|
||||
from theHarvester.lib.takeover_evidence import parse_takeover_details
|
||||
|
||||
from .run_models import _normalize_target
|
||||
|
||||
@@ -138,6 +139,44 @@ def validate_evidence(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
result['actions'] = sorted(set(actions))
|
||||
result['details'] = shodan_host.to_details()
|
||||
continue
|
||||
if result.get('type') == 'takeover':
|
||||
allowed_keys = {'type', 'value', 'sources', 'actions', 'details'}
|
||||
if set(result) - allowed_keys:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Takeover 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='Takeover 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='Takeover evidence must identify a canonical hostname',
|
||||
)
|
||||
try:
|
||||
outcome = parse_takeover_details(value, result.get('details'))
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
if outcome.hostname != value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Takeover evidence must identify a canonical hostname',
|
||||
)
|
||||
result['sources'] = sorted(set(sources))
|
||||
result['actions'] = sorted(set(actions))
|
||||
result['details'] = outcome.to_details()
|
||||
continue
|
||||
if result.get('type') == 'prefix':
|
||||
allowed_keys = {'type', 'value', 'sources', 'actions', 'scope', 'observations'}
|
||||
if set(result) - allowed_keys:
|
||||
|
||||
@@ -162,7 +162,11 @@ class RunRequest(BaseModel):
|
||||
)
|
||||
takeover: bool = Field(
|
||||
default=False,
|
||||
description='Check discovered hosts for takeover indicators, using configured proxies when enabled.',
|
||||
description=(
|
||||
'Check discovered hosts for DNS provider-gated takeover indicators, with wildcard controls and no redirects. '
|
||||
'Requests use configured proxies when enabled and fail closed when none are available. '
|
||||
'Indicators are not confirmed takeovers.'
|
||||
),
|
||||
)
|
||||
api_scan: bool = Field(
|
||||
default=False,
|
||||
@@ -464,6 +468,47 @@ class ShodanHostDetailsResponse(BaseModel):
|
||||
services: list[ShodanServiceResponse] = Field(min_length=1)
|
||||
|
||||
|
||||
class TakeoverDNSOutcomeResponse(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
resolver: str
|
||||
cname_chain: list[str]
|
||||
terminal_rcode: Literal['NOERROR', 'NXDOMAIN', 'NODATA', 'ERROR']
|
||||
error_type: str | None = None
|
||||
|
||||
|
||||
class TakeoverHTTPOutcomeResponse(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
scheme: Literal['http', 'https']
|
||||
status: int | None = Field(default=None, ge=100, le=599)
|
||||
location: str | None = None
|
||||
error_type: str | None = None
|
||||
body_truncated: bool = False
|
||||
|
||||
|
||||
class TakeoverIndicatorResponse(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
classification: Literal['vulnerable-indicator', 'unverified-indicator', 'edge-case']
|
||||
service: str
|
||||
rule_id: str
|
||||
rule_revision: str
|
||||
scheme: Literal['http', 'https'] | None = None
|
||||
matched: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class TakeoverDetailsResponse(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
status: Literal['indicator', 'no-indicator', 'inconclusive']
|
||||
dns: list[TakeoverDNSOutcomeResponse] = Field(min_length=1)
|
||||
wildcard_dns: list[TakeoverDNSOutcomeResponse]
|
||||
http: list[TakeoverHTTPOutcomeResponse]
|
||||
indicators: list[TakeoverIndicatorResponse]
|
||||
error_types: list[str]
|
||||
|
||||
|
||||
class NormalizedResult(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
|
||||
@@ -472,9 +517,9 @@ 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(
|
||||
details: ShodanHostDetailsResponse | TakeoverDetailsResponse | None = Field(
|
||||
default=None,
|
||||
description='Canonical host and service evidence for shodan-host results.',
|
||||
description='Canonical structured evidence for Shodan host and takeover results.',
|
||||
)
|
||||
observations: (
|
||||
list[
|
||||
@@ -494,12 +539,21 @@ class NormalizedResult(BaseModel):
|
||||
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:
|
||||
if self.scope is not None or self.observations is not None or not isinstance(self.details, ShodanHostDetailsResponse):
|
||||
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 == 'takeover':
|
||||
from theHarvester.lib.takeover_evidence import parse_takeover_details
|
||||
|
||||
if self.scope is not None or self.observations is not None or not isinstance(self.details, TakeoverDetailsResponse):
|
||||
raise ValueError('Takeover results require details without scope or observations')
|
||||
takeover_record = self.details.model_dump(exclude_none=True, exclude_defaults=True)
|
||||
outcome = parse_takeover_details(self.value, takeover_record)
|
||||
if outcome.hostname != self.value or outcome.to_details() != takeover_record:
|
||||
raise ValueError('Takeover results must use canonical structured details')
|
||||
elif self.type == 'prefix':
|
||||
if self.scope != 'external-relationship':
|
||||
raise ValueError('Prefix results must have external-relationship scope')
|
||||
@@ -520,7 +574,7 @@ class NormalizedResult(BaseModel):
|
||||
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')
|
||||
raise ValueError('Structured details belong to Shodan host or takeover results')
|
||||
return self
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from theHarvester.lib.database import DuplicateRunError, ResultStore, ResultStor
|
||||
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 theHarvester.lib.takeover_evidence import TakeoverCandidateOutcome, parse_takeover_details
|
||||
|
||||
from .run_artifacts import RunPaths, read_child_evidence
|
||||
from .run_models import RunRequest, _normalize_target, utc_now
|
||||
@@ -57,6 +58,7 @@ def _completed_result(
|
||||
network_observations: list[NetworkObservation] = []
|
||||
asn_attributions: list[AsnAttributionObservation] = []
|
||||
shodan_hosts: list[ShodanHostObservation] = []
|
||||
takeover_outcomes: list[TakeoverCandidateOutcome] = []
|
||||
for item in results:
|
||||
kind = cast('ResultKind', str(item['type']))
|
||||
value = str(item['value'])
|
||||
@@ -69,6 +71,8 @@ def _completed_result(
|
||||
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')))
|
||||
elif kind == 'takeover':
|
||||
takeover_outcomes.append(parse_takeover_details(value, item.get('details')))
|
||||
for source in set(item.get('sources', [])):
|
||||
source_name = str(source)
|
||||
source_origins.add(ResultObservation(source_name, kind, value))
|
||||
@@ -157,6 +161,7 @@ def _completed_result(
|
||||
network_observations=network_observations,
|
||||
asn_attributions=asn_attributions,
|
||||
shodan_hosts=shodan_hosts,
|
||||
takeover_outcomes=takeover_outcomes,
|
||||
evidence_status=(
|
||||
cast('EvidenceStatus', str(evidence['status']))
|
||||
if evidence.get('status') is not None and not execution_status_is_authoritative
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
];
|
||||
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-host': 'Shodan hosts',
|
||||
person: 'People', 'person-link': 'People links', takeover: 'Takeover outcomes', 'shodan-host': 'Shodan hosts',
|
||||
'scope-extension': 'Scope extensions', 'external-relationship': 'External relationships', other: 'Other'
|
||||
};
|
||||
const ACTION_FIELDS = {'dns-recursive': 'dns_recursive_depth'};
|
||||
@@ -558,6 +558,53 @@
|
||||
return JSON.stringify(rowValue || {}).toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
function takeoverStatusFormatter(cell) {
|
||||
const details = cell.getValue() || {};
|
||||
const status = String(details.status || 'not recorded').replaceAll('-', ' ');
|
||||
const errors = Array.isArray(details.error_types) && details.error_types.length
|
||||
? `<span>Errors: ${escapeHtml(details.error_types.join(', '))}</span>`
|
||||
: '';
|
||||
return `<div class="vhost-observations"><span>${escapeHtml(status)}</span>${errors}</div>`;
|
||||
}
|
||||
|
||||
function takeoverIndicatorsFormatter(cell) {
|
||||
const indicators = Array.isArray(cell.getValue()?.indicators) ? cell.getValue().indicators : [];
|
||||
if (!indicators.length) return 'No takeover indicator';
|
||||
return `<div class="vhost-observations">${indicators.map(indicator => {
|
||||
const classification = String(indicator.classification || 'indicator').replaceAll('-', ' ');
|
||||
const rule = [indicator.rule_id, indicator.rule_revision].filter(Boolean).join('@');
|
||||
const matched = Array.isArray(indicator.matched) ? indicator.matched.join(', ') : '';
|
||||
const scheme = indicator.scheme ? indicator.scheme.toUpperCase() : '';
|
||||
return `<span>${escapeHtml([classification, indicator.service, rule, scheme, matched].filter(Boolean).join(' · '))}</span>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
function takeoverEvidenceFormatter(cell) {
|
||||
const details = cell.getValue() || {};
|
||||
const dnsLines = (label, outcomes) => (Array.isArray(outcomes) ? outcomes : []).map(outcome => {
|
||||
const chain = Array.isArray(outcome.cname_chain) && outcome.cname_chain.length
|
||||
? `CNAME ${outcome.cname_chain.join(' → ')}`
|
||||
: 'No CNAME';
|
||||
const error = outcome.error_type ? ` · ${outcome.error_type}` : '';
|
||||
return `${label} ${outcome.resolver || 'unknown resolver'} · ${chain} · ${outcome.terminal_rcode || 'unknown RCODE'}${error}`;
|
||||
});
|
||||
const http = (Array.isArray(details.http) ? details.http : []).map(outcome => {
|
||||
const status = outcome.status == null ? 'no response' : `HTTP ${outcome.status}`;
|
||||
const location = outcome.location ? ` · location ${outcome.location}` : '';
|
||||
const error = outcome.error_type ? ` · ${outcome.error_type}` : '';
|
||||
const truncated = outcome.body_truncated ? ' · body truncated' : '';
|
||||
return `${String(outcome.scheme || 'http').toUpperCase()} · ${status}${location}${error}${truncated}`;
|
||||
});
|
||||
const lines = [
|
||||
...dnsLines('Candidate', details.dns),
|
||||
...dnsLines('Wildcard control', details.wildcard_dns),
|
||||
...http,
|
||||
];
|
||||
return lines.length
|
||||
? `<div class="vhost-observations">${lines.map(value => `<span>${escapeHtml(value)}</span>`).join('')}</div>`
|
||||
: 'No evidence recorded';
|
||||
}
|
||||
|
||||
function provenanceFormatter(cell) {
|
||||
const values = Array.isArray(cell.getValue()) ? cell.getValue() : [];
|
||||
return escapeHtml(values.join(', ') || '-');
|
||||
@@ -570,7 +617,7 @@
|
||||
const columns = [
|
||||
{title: state.route === 'shodan-host' ? 'IP' : 'Value', field: 'value', formatter: cell => `<span class="value-cell">${escapeHtml(cell.getValue())}</span>`, minWidth: 200, widthGrow: 2, responsive: 0, headerFilter: 'input', headerFilterFunc: columnTextFilter, headerFilterPlaceholder: 'Filter values'},
|
||||
];
|
||||
if (state.route !== 'shodan-host') {
|
||||
if (state.route !== 'shodan-host' && state.route !== 'takeover') {
|
||||
columns.push(
|
||||
{title: 'DNS', field: 'dns_status', formatter: dnsFormatter, width: 130, responsive: 1, headerFilter: 'input', headerFilterFunc: columnTextFilter, headerFilterPlaceholder: 'Filter DNS'},
|
||||
);
|
||||
@@ -603,6 +650,14 @@
|
||||
{title: 'Produced by', field: 'actions', formatter: provenanceFormatter, minWidth: 130, responsive: 2, headerFilter: 'input', headerFilterFunc: columnTextFilter},
|
||||
);
|
||||
}
|
||||
if (state.route === 'takeover') {
|
||||
columns.push(
|
||||
{title: 'Outcome', field: 'details', formatter: takeoverStatusFormatter, minWidth: 150, widthGrow: 1, headerFilter: 'input', headerFilterFunc: shodanDetailsFilter, headerFilterPlaceholder: 'Filter status or errors'},
|
||||
{title: 'Indicators', field: 'details', formatter: takeoverIndicatorsFormatter, minWidth: 300, widthGrow: 3, variableHeight: true, headerFilter: 'input', headerFilterFunc: shodanDetailsFilter, headerFilterPlaceholder: 'Filter provider or rule'},
|
||||
{title: 'DNS and HTTP evidence', field: 'details', formatter: takeoverEvidenceFormatter, minWidth: 420, widthGrow: 4, variableHeight: true, headerFilter: 'input', headerFilterFunc: shodanDetailsFilter, headerFilterPlaceholder: 'Filter resolver, CNAME, status, or error'},
|
||||
{title: 'Produced by', field: 'actions', formatter: provenanceFormatter, minWidth: 130, responsive: 2, headerFilter: 'input', headerFilterFunc: columnTextFilter},
|
||||
);
|
||||
}
|
||||
if (state.route === 'hostname') {
|
||||
columns.push({
|
||||
title: 'Actions', field: 'actions', formatter: resultActionFormatter, headerSort: false,
|
||||
|
||||
@@ -259,7 +259,7 @@
|
||||
<textarea id="api-scan-paths" name="api_scan_paths" rows="3" placeholder="/api/v2 /health"></textarea>
|
||||
<small>Optional list for API endpoint interaction, one URL path per line. Leave empty to use the bundled list.</small>
|
||||
</label>
|
||||
<label class="inline-choice"><input type="checkbox" name="proxies"><span>Use configured proxies for discovery and takeover checks</span></label>
|
||||
<label class="inline-choice"><input type="checkbox" name="proxies"><span>Use configured proxies for discovery and takeover checks. Takeover stops if no proxy is available.</span></label>
|
||||
<label class="inline-choice"><input type="checkbox" name="no_hosts"><span>Exclude hostname results and host-dependent processing</span></label>
|
||||
</div>
|
||||
</details>
|
||||
@@ -292,7 +292,7 @@
|
||||
<label class="action-choice p1"><input type="checkbox" name="dns_resolve"><span><strong>P1 · DNS resolution</strong><small>Resolve with the configured resolver addresses. Deduplicate names; use up to 20 hostname jobs with per-query resolver timeouts and no default phase ceiling.</small></span></label>
|
||||
<label class="action-choice p1"><input type="checkbox" name="dns_brute"><span><strong>P1 · DNS brute force</strong><small>Query candidate labels against DNS.</small></span></label>
|
||||
<label class="action-choice p2"><input type="checkbox" name="screenshot"><span><strong>P2 · Screenshots</strong><small>Contact resolved hosts and capture pages.</small></span></label>
|
||||
<label class="action-choice p2"><input type="checkbox" name="takeover"><span><strong>P2 · Takeover checks</strong><small>Contact discovered hosts, through configured proxies when enabled, for takeover evidence.</small></span></label>
|
||||
<label class="action-choice p2"><input type="checkbox" name="takeover"><span><strong>P2 · Takeover checks</strong><small>Use provider DNS and wildcard controls before contacting an original discovered host. Redirects stay off. A match is not confirmation.</small></span></label>
|
||||
<label class="action-choice p2"><input type="checkbox" name="api_scan"><span><strong>P2 · API endpoint interaction</strong><small>Contact the target for endpoint evidence.</small></span></label>
|
||||
</div>
|
||||
<details class="advanced-execution">
|
||||
|
||||
@@ -34,6 +34,11 @@ from theHarvester.lib.network_evidence import (
|
||||
)
|
||||
from theHarvester.lib.result_values import normalize_result_value
|
||||
from theHarvester.lib.shodan_evidence import ShodanHostObservation, canonical_shodan_hosts
|
||||
from theHarvester.lib.takeover_evidence import (
|
||||
TakeoverCandidateOutcome,
|
||||
canonical_takeover_outcomes,
|
||||
parse_takeover_details,
|
||||
)
|
||||
from theHarvester.lib.virtual_host import VirtualHostObservation
|
||||
|
||||
|
||||
@@ -92,6 +97,8 @@ def parse_result_jsonl(payload: bytes | str) -> tuple[dict[str, object], list[di
|
||||
allowed_keys.add('observations')
|
||||
if result_kind == 'shodan-host' and 'details' in record:
|
||||
allowed_keys.add('details')
|
||||
if result_kind == 'takeover' and 'details' in record:
|
||||
allowed_keys.add('details')
|
||||
if result_kind == 'prefix':
|
||||
allowed_keys.add('scope')
|
||||
if (
|
||||
@@ -144,6 +151,12 @@ def parse_result_jsonl(payload: bytes | str) -> tuple[dict[str, object], list[di
|
||||
if shodan_host.ip != record['value'] or shodan_host.to_details() != record.get('details'):
|
||||
raise ValueError('JSONL Shodan host must use canonical structured details')
|
||||
record['details'] = shodan_host.to_details()
|
||||
elif result_kind == 'takeover':
|
||||
try:
|
||||
takeover_outcome = parse_takeover_details(record['value'], record.get('details'))
|
||||
except ValueError as error:
|
||||
raise ValueError(f'JSONL takeover result has invalid details: {error}') from error
|
||||
record['details'] = takeover_outcome.to_details()
|
||||
return summary, findings
|
||||
|
||||
|
||||
@@ -228,6 +241,7 @@ class CompletedResult:
|
||||
network_observations: tuple[NetworkObservation, ...] = ()
|
||||
asn_attributions: tuple[AsnAttributionObservation, ...] = ()
|
||||
shodan_hosts: tuple[ShodanHostObservation, ...] = ()
|
||||
takeover_outcomes: tuple[TakeoverCandidateOutcome, ...] = ()
|
||||
evidence_status: EvidenceStatus | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
@@ -351,6 +365,24 @@ class CompletedResult:
|
||||
shodan_observation.ip,
|
||||
) not in action_results:
|
||||
raise ValueError('Shodan host evidence must reference Shodan source or action provenance')
|
||||
sorted_takeover_outcomes = canonical_takeover_outcomes(self.takeover_outcomes)
|
||||
if self.takeover_outcomes != sorted_takeover_outcomes:
|
||||
raise ValueError('takeover outcomes must be deduplicated and sorted')
|
||||
if self.takeover_outcomes:
|
||||
try:
|
||||
takeover_scope = normalize_hostname(self.target)
|
||||
except ValueError as error:
|
||||
raise ValueError('takeover outcomes require a hostname run target scope') from error
|
||||
if any(
|
||||
outcome.hostname != takeover_scope and not outcome.hostname.endswith(f'.{takeover_scope}')
|
||||
for outcome in self.takeover_outcomes
|
||||
):
|
||||
raise ValueError('takeover outcome must remain inside the run target scope')
|
||||
structured_takeover_results = {('takeover', outcome.hostname) for outcome in self.takeover_outcomes}
|
||||
if structured_takeover_results != {result for result in result_set if result[0] == 'takeover'}:
|
||||
raise ValueError('takeover results must contain canonical structured evidence')
|
||||
if any(('takeover', 'takeover', outcome.hostname) not in action_results for outcome in self.takeover_outcomes):
|
||||
raise ValueError('takeover evidence must reference takeover action provenance')
|
||||
sorted_asn_attributions = canonical_asn_attributions(list(self.asn_attributions))
|
||||
if self.asn_attributions != sorted_asn_attributions:
|
||||
raise ValueError('ASN attributions must be deduplicated and sorted')
|
||||
@@ -392,6 +424,7 @@ class CompletedResult:
|
||||
network_observations: Iterable[NetworkObservation] = (),
|
||||
asn_attributions: Iterable[AsnAttributionObservation] = (),
|
||||
shodan_hosts: Iterable[ShodanHostObservation] = (),
|
||||
takeover_outcomes: Iterable[TakeoverCandidateOutcome] = (),
|
||||
evidence_status: EvidenceStatus | None = None,
|
||||
) -> Self:
|
||||
completed_active_evidence = active_evidence if active_evidence is not None else ActiveEvidence()
|
||||
@@ -412,6 +445,9 @@ class CompletedResult:
|
||||
completed_shodan_hosts = canonical_shodan_hosts(list(shodan_hosts))
|
||||
for shodan_host in completed_shodan_hosts:
|
||||
results.add(('shodan-host', shodan_host.ip))
|
||||
completed_takeover_outcomes = canonical_takeover_outcomes(list(takeover_outcomes))
|
||||
for takeover_outcome in completed_takeover_outcomes:
|
||||
results.add(('takeover', takeover_outcome.hostname))
|
||||
return cls(
|
||||
run_id=run_id or uuid4(),
|
||||
target=target.strip(),
|
||||
@@ -425,6 +461,7 @@ class CompletedResult:
|
||||
network_observations=canonical_network_observations(network_observations),
|
||||
asn_attributions=canonical_asn_attributions(list(asn_attributions)),
|
||||
shodan_hosts=completed_shodan_hosts,
|
||||
takeover_outcomes=completed_takeover_outcomes,
|
||||
evidence_status=evidence_status,
|
||||
)
|
||||
|
||||
@@ -491,6 +528,7 @@ class CompletedResult:
|
||||
for attribution in self.asn_attributions:
|
||||
attribution_by_asn.setdefault(attribution.asn, []).append(attribution)
|
||||
shodan_by_ip = {observation.ip: observation for observation in self.shodan_hosts}
|
||||
takeover_by_hostname = {outcome.hostname: outcome for outcome in self.takeover_outcomes}
|
||||
records: list[dict[str, object]] = []
|
||||
for kind, value in self.results:
|
||||
record: dict[str, object] = {
|
||||
@@ -510,5 +548,7 @@ class CompletedResult:
|
||||
record['observations'] = asn_attribution_details(tuple(asn_attributions))
|
||||
elif kind == 'shodan-host' and (shodan_host := shodan_by_ip.get(value)):
|
||||
record['details'] = shodan_host.to_details()
|
||||
elif kind == 'takeover' and (takeover_outcome := takeover_by_hostname.get(value)):
|
||||
record['details'] = takeover_outcome.to_details()
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
+67
-65
@@ -488,11 +488,19 @@ class AsyncFetcher:
|
||||
proxy_url: str | None = None,
|
||||
proxy_type: str | None = None,
|
||||
ssl_context: ssl.SSLContext | bool | None = None,
|
||||
cookie_jar: aiohttp.abc.AbstractCookieJar | None = None,
|
||||
) -> aiohttp.ClientSession:
|
||||
connector = None
|
||||
if proxy_url is not None or proxy_type is not None:
|
||||
if proxy_url is not None or proxy_type is not None or ssl_context is not None:
|
||||
connector = await cls._create_connector(proxy_url, proxy_type, ssl_context)
|
||||
return aiohttp.ClientSession(headers=headers, timeout=client_timeout, connector=connector)
|
||||
session_kwargs: dict[str, Any] = {
|
||||
'headers': headers,
|
||||
'timeout': client_timeout,
|
||||
'connector': connector,
|
||||
}
|
||||
if cookie_jar is not None:
|
||||
session_kwargs['cookie_jar'] = cookie_jar
|
||||
return aiohttp.ClientSession(**session_kwargs)
|
||||
|
||||
@staticmethod
|
||||
async def _read_response(
|
||||
@@ -751,29 +759,35 @@ class AsyncFetcher:
|
||||
cls,
|
||||
url: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
params: Sized = '',
|
||||
proxy: str | bool | None = '',
|
||||
headers: dict[str, str] | None = None,
|
||||
follow_redirects: bool = False,
|
||||
request_timeout: int | None = 60,
|
||||
) -> AsyncIterator[aiohttp.ClientResponse]:
|
||||
owns_session = session is None
|
||||
proxy_url, proxy_type = cls._resolve_proxy(proxy)
|
||||
ssl_arg: ssl.SSLContext | bool | None = None
|
||||
if owns_session:
|
||||
try:
|
||||
ssl_arg = cls._ssl_context()
|
||||
proxy_url, proxy_type = cls._resolve_proxy(proxy)
|
||||
session = await cls._build_session(
|
||||
cls._default_headers(headers),
|
||||
cls._request_timeout(request_timeout),
|
||||
aiohttp.ClientTimeout(total=request_timeout),
|
||||
proxy_url,
|
||||
proxy_type,
|
||||
ssl_arg,
|
||||
)
|
||||
except (aiohttp.ClientError, TimeoutError, OSError, ssl.SSLError, ValueError) as error:
|
||||
raise ResponseStreamError('transport-error') from error
|
||||
assert session is not None
|
||||
try:
|
||||
request_kwargs: dict[str, Any] = {
|
||||
'ssl': ssl_arg,
|
||||
'allow_redirects': follow_redirects,
|
||||
}
|
||||
request_kwargs: dict[str, Any] = {'allow_redirects': follow_redirects}
|
||||
if owns_session:
|
||||
request_kwargs['ssl'] = ssl_arg
|
||||
elif headers is not None:
|
||||
request_kwargs['headers'] = cls._default_headers(headers)
|
||||
if proxy_url and proxy_type == 'http':
|
||||
request_kwargs['proxy'] = proxy_url
|
||||
if params != '':
|
||||
@@ -785,6 +799,7 @@ class AsyncFetcher:
|
||||
raise ResponseStreamError('transport-error') from error
|
||||
yield response
|
||||
finally:
|
||||
if owns_session:
|
||||
await session.close()
|
||||
|
||||
@classmethod
|
||||
@@ -826,6 +841,50 @@ class AsyncFetcher:
|
||||
raise ResponseStreamError('invalid-response') from error
|
||||
return FetcherResponse(body=parsed, status=response.status, headers=response_headers)
|
||||
|
||||
@classmethod
|
||||
async def fetch_text(
|
||||
cls,
|
||||
url: str,
|
||||
*,
|
||||
session: aiohttp.ClientSession | None = None,
|
||||
proxy: str | bool | None = '',
|
||||
headers: dict[str, str] | None = None,
|
||||
follow_redirects: bool = False,
|
||||
request_timeout: int | None = None,
|
||||
response_byte_limit: int = MAX_PROVIDER_JSON_BYTES,
|
||||
) -> FetcherResponse:
|
||||
"""Fetch one bounded text response while preserving status and headers."""
|
||||
if isinstance(response_byte_limit, bool) or not isinstance(response_byte_limit, int) or response_byte_limit <= 0:
|
||||
raise ValueError('response byte limit must be greater than zero')
|
||||
async with cls._open_get_response(
|
||||
url,
|
||||
session=session,
|
||||
proxy=proxy,
|
||||
headers=headers,
|
||||
follow_redirects=follow_redirects,
|
||||
request_timeout=request_timeout,
|
||||
) as response:
|
||||
response_headers = {name.lower(): value for name, value in response.headers.items()}
|
||||
try:
|
||||
try:
|
||||
if int(response_headers.get('content-length', '0')) > response_byte_limit:
|
||||
raise ResponseStreamError('response-limit')
|
||||
except ValueError:
|
||||
pass
|
||||
body = bytearray()
|
||||
async for chunk in _bounded_response_chunks(response.content, response_byte_limit):
|
||||
body.extend(chunk)
|
||||
except ResponseStreamError as error:
|
||||
if error.status is None:
|
||||
error.status = response.status
|
||||
error.headers = response_headers
|
||||
raise
|
||||
return FetcherResponse(
|
||||
body=bytes(body).decode(getattr(response, 'charset', None) or 'utf-8', errors='replace'),
|
||||
status=response.status,
|
||||
headers=response_headers,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@contextlib.asynccontextmanager
|
||||
async def stream_records(
|
||||
@@ -862,25 +921,6 @@ class AsyncFetcher:
|
||||
_framing=framing,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def takeover_fetch(
|
||||
session,
|
||||
url: str,
|
||||
proxy: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
include_metadata: bool = False,
|
||||
) -> tuple[Any, Any] | str:
|
||||
_, proxy_type = AsyncFetcher._resolve_proxy(proxy)
|
||||
response = await AsyncFetcher.fetch(
|
||||
session=None if proxy_type == 'socks5' else session,
|
||||
url=url,
|
||||
proxy=proxy,
|
||||
headers=headers,
|
||||
request_timeout=15,
|
||||
include_metadata=include_metadata,
|
||||
)
|
||||
return url, response
|
||||
|
||||
@classmethod
|
||||
async def fetch_all(
|
||||
cls,
|
||||
@@ -888,50 +928,12 @@ class AsyncFetcher:
|
||||
headers=None,
|
||||
params: Sized = '',
|
||||
json: bool = False,
|
||||
takeover: bool = False,
|
||||
proxy: bool = False,
|
||||
include_metadata: bool = False,
|
||||
) -> list:
|
||||
# By default, timeout is 5 minutes; 60 seconds should suffice
|
||||
headers = cls._default_headers(headers)
|
||||
timeout = cls._request_timeout(60)
|
||||
if takeover:
|
||||
async with aiohttp.ClientSession(
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as session:
|
||||
if proxy:
|
||||
# Get random proxy for each URL
|
||||
proxy_urls = [cls._get_random_proxy(cls().proxy_list)[0] for _ in urls]
|
||||
return list(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
AsyncFetcher.takeover_fetch(
|
||||
session,
|
||||
url,
|
||||
proxy=proxy_url,
|
||||
headers=headers,
|
||||
include_metadata=include_metadata,
|
||||
)
|
||||
for url, proxy_url in zip(urls, proxy_urls, strict=False)
|
||||
]
|
||||
)
|
||||
)
|
||||
else:
|
||||
return list(
|
||||
await asyncio.gather(
|
||||
*[
|
||||
AsyncFetcher.takeover_fetch(
|
||||
session,
|
||||
url,
|
||||
headers=headers,
|
||||
include_metadata=include_metadata,
|
||||
)
|
||||
for url in urls
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if len(params) == 0:
|
||||
async with aiohttp.ClientSession(headers=headers, timeout=timeout) as session:
|
||||
if proxy:
|
||||
|
||||
@@ -62,6 +62,11 @@ from theHarvester.lib.network_evidence import (
|
||||
parse_network_observation_json,
|
||||
)
|
||||
from theHarvester.lib.shodan_evidence import ShodanHostObservation, canonical_shodan_hosts
|
||||
from theHarvester.lib.takeover_evidence import (
|
||||
TakeoverCandidateOutcome,
|
||||
canonical_takeover_outcomes,
|
||||
parse_takeover_details,
|
||||
)
|
||||
from theHarvester.lib.virtual_host import VirtualHostObservation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -754,6 +759,7 @@ class ResultStore:
|
||||
for network_observation in result.network_observations:
|
||||
network_by_prefix.setdefault(network_observation.prefix, []).append(network_observation)
|
||||
shodan_by_ip = {observation.ip: observation for observation in result.shodan_hosts}
|
||||
takeover_by_hostname = {outcome.hostname: outcome for outcome in result.takeover_outcomes}
|
||||
async with self._session() as session:
|
||||
try:
|
||||
session.add(
|
||||
@@ -794,6 +800,13 @@ class ResultStore:
|
||||
sort_keys=True,
|
||||
)
|
||||
if kind == 'shodan-host' and value in shodan_by_ip
|
||||
else json.dumps(
|
||||
takeover_by_hostname[value].to_details(),
|
||||
ensure_ascii=False,
|
||||
separators=(',', ':'),
|
||||
sort_keys=True,
|
||||
)
|
||||
if kind == 'takeover' and value in takeover_by_hostname
|
||||
else None
|
||||
),
|
||||
)
|
||||
@@ -911,6 +924,7 @@ class ResultStore:
|
||||
virtual_hosts: list[VirtualHostObservation] = []
|
||||
network_observations: list[NetworkObservation] = []
|
||||
shodan_hosts: list[ShodanHostObservation] = []
|
||||
takeover_outcomes: list[TakeoverCandidateOutcome] = []
|
||||
for result_row in rows:
|
||||
has_vhost_provenance = result_row.position in vhost_result_positions
|
||||
if has_vhost_provenance:
|
||||
@@ -937,6 +951,18 @@ class ResultStore:
|
||||
raise ResultStoreError(f'Persisted Shodan host details are not canonical: {result_row.value}')
|
||||
shodan_hosts.append(shodan_host)
|
||||
continue
|
||||
if result_row.kind == 'takeover':
|
||||
if result_row.details_json is None:
|
||||
raise ResultStoreError(f'Persisted takeover details are missing: {result_row.value}')
|
||||
try:
|
||||
details = json.loads(result_row.details_json)
|
||||
takeover_outcome = parse_takeover_details(result_row.value, details)
|
||||
except (json.JSONDecodeError, ValueError) as error:
|
||||
raise ResultStoreError(f'Persisted takeover details are invalid: {result_row.value}') from error
|
||||
if takeover_outcome.to_details() != details:
|
||||
raise ResultStoreError(f'Persisted takeover details are not canonical: {result_row.value}')
|
||||
takeover_outcomes.append(takeover_outcome)
|
||||
continue
|
||||
if result_row.details_json is None:
|
||||
continue
|
||||
if result_row.kind == 'prefix':
|
||||
@@ -1065,6 +1091,7 @@ class ResultStore:
|
||||
network_observations=tuple(sorted(set(network_observations), key=network_observation_sort_key)),
|
||||
asn_attributions=canonical_attributions,
|
||||
shodan_hosts=canonical_shodan_hosts(shodan_hosts),
|
||||
takeover_outcomes=canonical_takeover_outcomes(takeover_outcomes),
|
||||
evidence_status=cast('EvidenceStatus', parent.evidence_status) if parent.evidence_status is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from theHarvester.lib.virtual_host import (
|
||||
|
||||
DEFAULT_RESULT_LIMIT = 500
|
||||
DEFAULT_RESULT_START = 0
|
||||
DEFAULT_SOURCE_WORKERS = 6
|
||||
DEFAULT_SOURCE_WORKERS = 3
|
||||
DEFAULT_DNS_RECURSIVE_QUERY_LIMIT = DEFAULT_RECURSIVE_DNS_QUERY_LIMIT
|
||||
DEFAULT_DNS_RECURSIVE_RUNTIME_SECONDS = DEFAULT_RECURSIVE_DNS_RUNTIME_SECONDS
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
||||
from ipaddress import ip_address, ip_network
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from theHarvester.lib.hostnames import normalize_hostname
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from theHarvester.lib.evidence_types import ResultKind
|
||||
|
||||
@@ -44,4 +46,6 @@ def normalize_result_value(kind: ResultKind | str, value: str) -> str:
|
||||
if '%' in normalized:
|
||||
raise ValueError('Shodan host must not contain an IPv6 scope identifier')
|
||||
return str(ip_address(normalized))
|
||||
if kind == 'takeover':
|
||||
return normalize_hostname(normalized)
|
||||
return normalized
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address
|
||||
from typing import Literal, Self, cast
|
||||
|
||||
from theHarvester.lib.hostnames import normalize_hostname
|
||||
|
||||
TakeoverClassification = Literal['vulnerable-indicator', 'unverified-indicator', 'edge-case']
|
||||
TakeoverCandidateStatus = Literal['indicator', 'no-indicator', 'inconclusive']
|
||||
TakeoverRcode = Literal['NOERROR', 'NXDOMAIN', 'NODATA', 'ERROR']
|
||||
HttpScheme = Literal['http', 'https']
|
||||
|
||||
_CLASSIFICATIONS = {'vulnerable-indicator', 'unverified-indicator', 'edge-case'}
|
||||
_STATUSES = {'indicator', 'no-indicator', 'inconclusive'}
|
||||
_RCODES = {'NOERROR', 'NXDOMAIN', 'NODATA', 'ERROR'}
|
||||
_SCHEMES = {'http', 'https'}
|
||||
|
||||
|
||||
def _required_text(value: object, field: str, *, limit: int = 512) -> str:
|
||||
if not isinstance(value, str) or not (text := value.strip()) or len(text) > limit:
|
||||
raise ValueError(f'takeover {field} must be a non-empty string of at most {limit} characters')
|
||||
return text
|
||||
|
||||
|
||||
def _optional_text(value: object, field: str, *, limit: int = 2048) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return _required_text(value, field, limit=limit)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TakeoverDNSOutcome:
|
||||
"""One resolver's DNS relationship evidence for an in-scope hostname."""
|
||||
|
||||
resolver: str
|
||||
cname_chain: tuple[str, ...]
|
||||
terminal_rcode: TakeoverRcode
|
||||
error_type: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
canonical_resolver = str(ip_address(self.resolver))
|
||||
except ValueError as error:
|
||||
raise ValueError('takeover resolver must be a canonical IP address') from error
|
||||
canonical_chain = tuple(normalize_hostname(item) for item in self.cname_chain)
|
||||
if canonical_chain != self.cname_chain or len(set(canonical_chain)) != len(canonical_chain):
|
||||
raise ValueError('takeover CNAME chain must be canonical and contain no repeats')
|
||||
if self.terminal_rcode not in _RCODES:
|
||||
raise ValueError('takeover terminal RCODE is not supported')
|
||||
object.__setattr__(self, 'resolver', canonical_resolver)
|
||||
object.__setattr__(self, 'error_type', _optional_text(self.error_type, 'DNS error type'))
|
||||
if self.terminal_rcode == 'ERROR' and self.error_type is None:
|
||||
raise ValueError('takeover DNS ERROR outcomes require an error type')
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: object) -> Self:
|
||||
if not isinstance(record, dict) or set(record) - {
|
||||
'resolver',
|
||||
'cname_chain',
|
||||
'terminal_rcode',
|
||||
'error_type',
|
||||
}:
|
||||
raise ValueError('takeover DNS evidence contains unsupported fields')
|
||||
raw_chain = record.get('cname_chain', [])
|
||||
if not isinstance(raw_chain, list) or any(not isinstance(item, str) for item in raw_chain):
|
||||
raise ValueError('takeover CNAME chain must be an array of hostnames')
|
||||
return cls(
|
||||
resolver=_required_text(record.get('resolver'), 'resolver'),
|
||||
cname_chain=tuple(raw_chain),
|
||||
terminal_rcode=cast('TakeoverRcode', _required_text(record.get('terminal_rcode'), 'terminal RCODE')),
|
||||
error_type=_optional_text(record.get('error_type'), 'DNS error type'),
|
||||
)
|
||||
|
||||
def to_record(self) -> dict[str, object]:
|
||||
record: dict[str, object] = {
|
||||
'resolver': self.resolver,
|
||||
'cname_chain': list(self.cname_chain),
|
||||
'terminal_rcode': self.terminal_rcode,
|
||||
}
|
||||
if self.error_type is not None:
|
||||
record['error_type'] = self.error_type
|
||||
return record
|
||||
|
||||
def sort_key(self) -> tuple[object, ...]:
|
||||
return self.resolver, self.cname_chain, self.terminal_rcode, self.error_type or ''
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TakeoverHTTPOutcome:
|
||||
"""One bounded request outcome against the authorized hostname."""
|
||||
|
||||
scheme: HttpScheme
|
||||
status: int | None = None
|
||||
location: str | None = None
|
||||
error_type: str | None = None
|
||||
body_truncated: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.scheme not in _SCHEMES:
|
||||
raise ValueError('takeover HTTP scheme must be http or https')
|
||||
if self.status is not None and (
|
||||
isinstance(self.status, bool) or not isinstance(self.status, int) or not 100 <= self.status <= 599
|
||||
):
|
||||
raise ValueError('takeover HTTP status must be between 100 and 599')
|
||||
if not isinstance(self.body_truncated, bool):
|
||||
raise ValueError('takeover body_truncated must be a boolean')
|
||||
object.__setattr__(self, 'location', _optional_text(self.location, 'redirect location', limit=2048))
|
||||
object.__setattr__(self, 'error_type', _optional_text(self.error_type, 'HTTP error type'))
|
||||
if self.status is None and self.error_type is None:
|
||||
raise ValueError('takeover HTTP outcomes require a response status or error type')
|
||||
if self.body_truncated and self.error_type is None:
|
||||
raise ValueError('truncated takeover HTTP outcomes require an error type')
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: object) -> Self:
|
||||
if not isinstance(record, dict) or set(record) - {
|
||||
'scheme',
|
||||
'status',
|
||||
'location',
|
||||
'error_type',
|
||||
'body_truncated',
|
||||
}:
|
||||
raise ValueError('takeover HTTP evidence contains unsupported fields')
|
||||
return cls(
|
||||
scheme=cast('HttpScheme', _required_text(record.get('scheme'), 'HTTP scheme')),
|
||||
status=record.get('status') if record.get('status') is not None else None,
|
||||
location=_optional_text(record.get('location'), 'redirect location', limit=2048),
|
||||
error_type=_optional_text(record.get('error_type'), 'HTTP error type'),
|
||||
body_truncated=record.get('body_truncated', False),
|
||||
)
|
||||
|
||||
def to_record(self) -> dict[str, object]:
|
||||
record: dict[str, object] = {'scheme': self.scheme}
|
||||
if self.status is not None:
|
||||
record['status'] = self.status
|
||||
if self.location is not None:
|
||||
record['location'] = self.location
|
||||
if self.error_type is not None:
|
||||
record['error_type'] = self.error_type
|
||||
if self.body_truncated:
|
||||
record['body_truncated'] = True
|
||||
return record
|
||||
|
||||
def sort_key(self) -> tuple[object, ...]:
|
||||
return (
|
||||
self.scheme,
|
||||
self.status if self.status is not None else 0,
|
||||
self.location or '',
|
||||
self.error_type or '',
|
||||
self.body_truncated,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TakeoverIndicator:
|
||||
"""One provider rule matched by the evidence for a candidate hostname."""
|
||||
|
||||
classification: TakeoverClassification
|
||||
service: str
|
||||
rule_id: str
|
||||
rule_revision: str
|
||||
matched: tuple[str, ...]
|
||||
scheme: HttpScheme | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.classification not in _CLASSIFICATIONS:
|
||||
raise ValueError('takeover classification is not supported')
|
||||
if not self.matched or self.matched != tuple(sorted(set(self.matched))):
|
||||
raise ValueError('takeover matched predicates must be non-empty, deduplicated, and sorted')
|
||||
object.__setattr__(self, 'service', _required_text(self.service, 'service'))
|
||||
object.__setattr__(self, 'rule_id', _required_text(self.rule_id, 'rule ID'))
|
||||
object.__setattr__(self, 'rule_revision', _required_text(self.rule_revision, 'rule revision'))
|
||||
if self.scheme is not None and self.scheme not in _SCHEMES:
|
||||
raise ValueError('takeover indicator scheme must be http or https')
|
||||
for predicate in self.matched:
|
||||
_required_text(predicate, 'matched predicate', limit=512)
|
||||
dns_predicates = [predicate.startswith('dns:') for predicate in self.matched]
|
||||
if any(dns_predicates) and not all(dns_predicates):
|
||||
raise ValueError('takeover indicators cannot mix DNS and HTTP predicates')
|
||||
dns_only = all(dns_predicates)
|
||||
if dns_only and self.scheme is not None:
|
||||
raise ValueError('DNS-only takeover indicators cannot identify an HTTP scheme')
|
||||
if not dns_only and self.scheme is None:
|
||||
raise ValueError('HTTP takeover indicators must identify the matching scheme')
|
||||
if dns_only:
|
||||
rcodes = [predicate.removeprefix('dns:terminal-rcode=') for predicate in self.matched]
|
||||
if any(rcode not in _RCODES for rcode in rcodes):
|
||||
raise ValueError('DNS takeover indicators require canonical terminal RCODE predicates')
|
||||
else:
|
||||
allowed_prefixes = ('status:', 'body:', 'body-regex:', 'header:')
|
||||
if any(not predicate.startswith(allowed_prefixes) for predicate in self.matched):
|
||||
raise ValueError('HTTP takeover indicators contain an unsupported predicate')
|
||||
for predicate in self.matched:
|
||||
if not predicate.startswith('status:'):
|
||||
continue
|
||||
raw_status = predicate.removeprefix('status:')
|
||||
if not raw_status.isdigit() or not 100 <= int(raw_status) <= 599:
|
||||
raise ValueError('HTTP takeover indicators contain an invalid status predicate')
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record: object) -> Self:
|
||||
if not isinstance(record, dict) or set(record) - {
|
||||
'classification',
|
||||
'service',
|
||||
'rule_id',
|
||||
'rule_revision',
|
||||
'scheme',
|
||||
'matched',
|
||||
}:
|
||||
raise ValueError('takeover indicator must use the canonical fields')
|
||||
if not {'classification', 'service', 'rule_id', 'rule_revision', 'matched'} <= set(record):
|
||||
raise ValueError('takeover indicator must use the canonical fields')
|
||||
raw_matched = record.get('matched')
|
||||
if not isinstance(raw_matched, list) or any(not isinstance(item, str) for item in raw_matched):
|
||||
raise ValueError('takeover matched predicates must be an array of strings')
|
||||
return cls(
|
||||
classification=cast(
|
||||
'TakeoverClassification',
|
||||
_required_text(record.get('classification'), 'classification'),
|
||||
),
|
||||
service=_required_text(record.get('service'), 'service'),
|
||||
rule_id=_required_text(record.get('rule_id'), 'rule ID'),
|
||||
rule_revision=_required_text(record.get('rule_revision'), 'rule revision'),
|
||||
scheme=(cast('HttpScheme', _required_text(record.get('scheme'), 'HTTP scheme')) if 'scheme' in record else None),
|
||||
matched=tuple(raw_matched),
|
||||
)
|
||||
|
||||
def sort_key(self) -> tuple[str, str, str, str]:
|
||||
return self.rule_id, self.service, self.classification, self.scheme or ''
|
||||
|
||||
def to_record(self) -> dict[str, object]:
|
||||
record: dict[str, object] = {
|
||||
'classification': self.classification,
|
||||
'service': self.service,
|
||||
'rule_id': self.rule_id,
|
||||
'rule_revision': self.rule_revision,
|
||||
'matched': list(self.matched),
|
||||
}
|
||||
if self.scheme is not None:
|
||||
record['scheme'] = self.scheme
|
||||
return record
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TakeoverCandidateOutcome:
|
||||
"""Complete or explicitly inconclusive takeover evaluation for one hostname."""
|
||||
|
||||
hostname: str
|
||||
status: TakeoverCandidateStatus
|
||||
dns: tuple[TakeoverDNSOutcome, ...]
|
||||
wildcard_dns: tuple[TakeoverDNSOutcome, ...] = ()
|
||||
http: tuple[TakeoverHTTPOutcome, ...] = ()
|
||||
indicators: tuple[TakeoverIndicator, ...] = ()
|
||||
error_types: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
canonical_hostname = normalize_hostname(self.hostname)
|
||||
if canonical_hostname != self.hostname:
|
||||
raise ValueError('takeover hostname must be canonical')
|
||||
if self.status not in _STATUSES:
|
||||
raise ValueError('takeover candidate status is not supported')
|
||||
if not self.dns or self.dns != tuple(sorted(set(self.dns), key=TakeoverDNSOutcome.sort_key)):
|
||||
raise ValueError('takeover candidate DNS evidence must be non-empty, deduplicated, and sorted')
|
||||
if len({outcome.resolver for outcome in self.dns}) != len(self.dns):
|
||||
raise ValueError('takeover candidate must contain one DNS outcome per resolver')
|
||||
if self.wildcard_dns != tuple(sorted(set(self.wildcard_dns), key=TakeoverDNSOutcome.sort_key)):
|
||||
raise ValueError('takeover wildcard DNS evidence must be deduplicated and sorted')
|
||||
if len({outcome.resolver for outcome in self.wildcard_dns}) != len(self.wildcard_dns):
|
||||
raise ValueError('takeover candidate must contain one wildcard DNS outcome per resolver')
|
||||
if self.http != tuple(sorted(set(self.http), key=TakeoverHTTPOutcome.sort_key)):
|
||||
raise ValueError('takeover HTTP evidence must be deduplicated and sorted')
|
||||
if len({outcome.scheme for outcome in self.http}) != len(self.http):
|
||||
raise ValueError('takeover candidate must contain one HTTP outcome per scheme')
|
||||
if self.indicators != tuple(sorted(set(self.indicators), key=TakeoverIndicator.sort_key)):
|
||||
raise ValueError('takeover indicators must be deduplicated and sorted')
|
||||
rule_ids = [indicator.rule_id for indicator in self.indicators]
|
||||
if len(rule_ids) != len(set(rule_ids)):
|
||||
raise ValueError('takeover candidate cannot contain conflicting observations for one rule')
|
||||
if self.error_types != tuple(sorted(set(self.error_types))):
|
||||
raise ValueError('takeover candidate errors must be deduplicated and sorted')
|
||||
for error_type in self.error_types:
|
||||
_required_text(error_type, 'candidate error type')
|
||||
nested_errors = {outcome.error_type for outcome in self.dns if outcome.error_type is not None}
|
||||
nested_errors.update(outcome.error_type for outcome in self.wildcard_dns if outcome.error_type is not None)
|
||||
nested_errors.update(outcome.error_type for outcome in self.http if outcome.error_type is not None)
|
||||
if not nested_errors <= set(self.error_types):
|
||||
raise ValueError('takeover candidate errors must include every DNS and HTTP outcome error')
|
||||
if self.status == 'indicator' and not self.indicators:
|
||||
raise ValueError('takeover indicator status requires at least one indicator')
|
||||
if self.status != 'indicator' and self.indicators:
|
||||
raise ValueError('only takeover indicator status can contain indicators')
|
||||
if self.status == 'no-indicator' and self.error_types:
|
||||
raise ValueError('complete no-indicator outcomes cannot contain errors')
|
||||
if self.status == 'inconclusive' and not self.error_types:
|
||||
raise ValueError('inconclusive takeover outcomes require an error type')
|
||||
dns_by_resolver = {outcome.resolver: outcome for outcome in self.dns}
|
||||
wildcard_by_resolver = {outcome.resolver: outcome for outcome in self.wildcard_dns}
|
||||
if (self.indicators or self.http) and (
|
||||
any(outcome.error_type is not None for outcome in self.dns)
|
||||
or any(outcome.error_type is not None for outcome in self.wildcard_dns)
|
||||
):
|
||||
raise ValueError('takeover HTTP evaluation requires error-free candidate and wildcard DNS evidence')
|
||||
if self.indicators or self.http:
|
||||
if set(wildcard_by_resolver) != set(dns_by_resolver):
|
||||
raise ValueError('takeover HTTP evaluation requires one wildcard control per resolver')
|
||||
if len({(outcome.cname_chain, outcome.terminal_rcode) for outcome in self.dns}) != 1:
|
||||
raise ValueError('takeover HTTP evaluation requires candidate DNS agreement across resolvers')
|
||||
if len({(outcome.cname_chain, outcome.terminal_rcode) for outcome in self.wildcard_dns}) != 1:
|
||||
raise ValueError('takeover HTTP evaluation requires wildcard DNS agreement across resolvers')
|
||||
if any(
|
||||
dns_by_resolver[resolver].cname_chain == wildcard.cname_chain
|
||||
and dns_by_resolver[resolver].terminal_rcode == wildcard.terminal_rcode
|
||||
for resolver, wildcard in wildcard_by_resolver.items()
|
||||
):
|
||||
raise ValueError('takeover HTTP evaluation requires resolver-distinct wildcard controls')
|
||||
for indicator in self.indicators:
|
||||
if indicator.scheme is None:
|
||||
matched_rcodes = {
|
||||
predicate.removeprefix('dns:terminal-rcode=')
|
||||
for predicate in indicator.matched
|
||||
if predicate.startswith('dns:terminal-rcode=')
|
||||
}
|
||||
if len(matched_rcodes) != len(indicator.matched) or not matched_rcodes <= _RCODES:
|
||||
raise ValueError('DNS takeover indicators require canonical terminal RCODE predicates')
|
||||
if any(outcome.terminal_rcode not in matched_rcodes or outcome.error_type for outcome in self.dns):
|
||||
raise ValueError('DNS takeover indicators must agree with every resolver outcome')
|
||||
else:
|
||||
matching_http = [outcome for outcome in self.http if outcome.scheme == indicator.scheme]
|
||||
if (
|
||||
len(matching_http) != 1
|
||||
or matching_http[0].status is None
|
||||
or matching_http[0].error_type is not None
|
||||
or matching_http[0].body_truncated
|
||||
):
|
||||
raise ValueError('HTTP takeover indicators require one successful outcome for their matching scheme')
|
||||
matched_statuses = {
|
||||
int(predicate.removeprefix('status:'))
|
||||
for predicate in indicator.matched
|
||||
if predicate.startswith('status:') and predicate.removeprefix('status:').isdigit()
|
||||
}
|
||||
if matched_statuses and matching_http[0].status not in matched_statuses:
|
||||
raise ValueError('HTTP takeover status predicates must agree with their matching outcome')
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, hostname: str, details: object) -> Self:
|
||||
if not isinstance(details, dict) or set(details) != {
|
||||
'status',
|
||||
'dns',
|
||||
'wildcard_dns',
|
||||
'http',
|
||||
'indicators',
|
||||
'error_types',
|
||||
}:
|
||||
raise ValueError('takeover details must use the canonical candidate fields')
|
||||
raw_dns = details.get('dns')
|
||||
raw_wildcard_dns = details.get('wildcard_dns')
|
||||
raw_http = details.get('http')
|
||||
raw_indicators = details.get('indicators')
|
||||
raw_errors = details.get('error_types')
|
||||
if (
|
||||
not isinstance(raw_dns, list)
|
||||
or not isinstance(raw_wildcard_dns, list)
|
||||
or not isinstance(raw_http, list)
|
||||
or not isinstance(raw_indicators, list)
|
||||
or not isinstance(raw_errors, list)
|
||||
):
|
||||
raise ValueError('takeover detail collections must be arrays')
|
||||
if any(not isinstance(item, str) for item in raw_errors):
|
||||
raise ValueError('takeover candidate errors must be an array of strings')
|
||||
return cls(
|
||||
hostname=normalize_hostname(hostname),
|
||||
status=cast('TakeoverCandidateStatus', _required_text(details.get('status'), 'candidate status')),
|
||||
dns=tuple(sorted({TakeoverDNSOutcome.from_record(item) for item in raw_dns}, key=TakeoverDNSOutcome.sort_key)),
|
||||
wildcard_dns=tuple(
|
||||
sorted(
|
||||
{TakeoverDNSOutcome.from_record(item) for item in raw_wildcard_dns},
|
||||
key=TakeoverDNSOutcome.sort_key,
|
||||
)
|
||||
),
|
||||
http=tuple(sorted({TakeoverHTTPOutcome.from_record(item) for item in raw_http}, key=TakeoverHTTPOutcome.sort_key)),
|
||||
indicators=tuple(
|
||||
sorted({TakeoverIndicator.from_record(item) for item in raw_indicators}, key=TakeoverIndicator.sort_key)
|
||||
),
|
||||
error_types=tuple(sorted(set(raw_errors))),
|
||||
)
|
||||
|
||||
def sort_key(self) -> str:
|
||||
return self.hostname
|
||||
|
||||
def to_details(self) -> dict[str, object]:
|
||||
return {
|
||||
'status': self.status,
|
||||
'dns': [item.to_record() for item in self.dns],
|
||||
'wildcard_dns': [item.to_record() for item in self.wildcard_dns],
|
||||
'http': [item.to_record() for item in self.http],
|
||||
'indicators': [item.to_record() for item in self.indicators],
|
||||
'error_types': list(self.error_types),
|
||||
}
|
||||
|
||||
|
||||
def canonical_takeover_outcomes(
|
||||
outcomes: list[TakeoverCandidateOutcome] | tuple[TakeoverCandidateOutcome, ...],
|
||||
) -> tuple[TakeoverCandidateOutcome, ...]:
|
||||
by_hostname: dict[str, TakeoverCandidateOutcome] = {}
|
||||
for outcome in outcomes:
|
||||
existing = by_hostname.get(outcome.hostname)
|
||||
if existing is not None and existing != outcome:
|
||||
raise ValueError(f'conflicting takeover outcomes for {outcome.hostname}')
|
||||
by_hostname[outcome.hostname] = outcome
|
||||
return tuple(sorted(by_hostname.values(), key=TakeoverCandidateOutcome.sort_key))
|
||||
|
||||
|
||||
def parse_takeover_details(hostname: str, details: object) -> TakeoverCandidateOutcome:
|
||||
outcome = TakeoverCandidateOutcome.from_record(hostname, details)
|
||||
if outcome.to_details() != details:
|
||||
raise ValueError('takeover details must use canonical structured evidence')
|
||||
return outcome
|
||||
@@ -0,0 +1,242 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from theHarvester.lib.takeover_evidence import TakeoverClassification, TakeoverRcode
|
||||
|
||||
# This reviewed snapshot translates the provider-gated subset of
|
||||
# can-i-take-over-xyz@5bd4e128 and compound predicates from
|
||||
# nuclei-templates@9090ee10. Rules are local so network failure cannot silently
|
||||
# change coverage. See CHANGELOG.md and README.md for provenance and terminology.
|
||||
TAKEOVER_RULE_REVISION = 'takeover-rules-v1'
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TakeoverRule:
|
||||
rule_id: str
|
||||
service: str
|
||||
cname_patterns: tuple[str, ...]
|
||||
classification: TakeoverClassification = 'vulnerable-indicator'
|
||||
terminal_rcodes: tuple[TakeoverRcode, ...] = ()
|
||||
status_codes: tuple[int, ...] = ()
|
||||
body_all: tuple[str, ...] = ()
|
||||
body_any: tuple[str, ...] = ()
|
||||
body_none: tuple[str, ...] = ()
|
||||
body_regex_all: tuple[str, ...] = ()
|
||||
body_regex_any: tuple[str, ...] = ()
|
||||
body_regex_none: tuple[str, ...] = ()
|
||||
header_any: tuple[str, ...] = ()
|
||||
header_none: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.rule_id.strip() or not self.service.strip() or not self.cname_patterns:
|
||||
raise ValueError('takeover rules require an ID, service, and provider CNAME pattern')
|
||||
text_groups = (
|
||||
self.cname_patterns,
|
||||
self.body_all,
|
||||
self.body_any,
|
||||
self.body_none,
|
||||
self.body_regex_all,
|
||||
self.body_regex_any,
|
||||
self.body_regex_none,
|
||||
self.header_any,
|
||||
self.header_none,
|
||||
)
|
||||
if any(not value.strip() for group in text_groups for value in group):
|
||||
raise ValueError(f'takeover rule {self.rule_id} contains an empty matcher')
|
||||
for pattern in (*self.cname_patterns, *self.body_regex_all, *self.body_regex_any, *self.body_regex_none):
|
||||
try:
|
||||
re.compile(pattern, flags=re.IGNORECASE)
|
||||
except re.error as error:
|
||||
raise ValueError(f'takeover rule {self.rule_id} contains an invalid regular expression') from error
|
||||
if self.classification not in {'vulnerable-indicator', 'unverified-indicator', 'edge-case'}:
|
||||
raise ValueError(f'takeover rule {self.rule_id} has an unsupported classification')
|
||||
if any(rcode not in {'NOERROR', 'NXDOMAIN', 'NODATA', 'ERROR'} for rcode in self.terminal_rcodes):
|
||||
raise ValueError(f'takeover rule {self.rule_id} has an unsupported terminal RCODE')
|
||||
if any(isinstance(status, bool) or not 100 <= status <= 599 for status in self.status_codes):
|
||||
raise ValueError(f'takeover rule {self.rule_id} has an invalid HTTP status')
|
||||
http_predicates = (
|
||||
self.status_codes,
|
||||
self.body_all,
|
||||
self.body_any,
|
||||
self.body_none,
|
||||
self.body_regex_all,
|
||||
self.body_regex_any,
|
||||
self.body_regex_none,
|
||||
self.header_any,
|
||||
self.header_none,
|
||||
)
|
||||
if self.terminal_rcodes and any(http_predicates):
|
||||
raise ValueError(f'takeover rule {self.rule_id} cannot mix DNS and HTTP predicates')
|
||||
if not any(
|
||||
(
|
||||
self.terminal_rcodes,
|
||||
self.status_codes,
|
||||
self.body_all,
|
||||
self.body_any,
|
||||
self.body_regex_all,
|
||||
self.body_regex_any,
|
||||
self.header_any,
|
||||
)
|
||||
):
|
||||
raise ValueError(f'takeover rule {self.rule_id} has no positive predicate')
|
||||
|
||||
|
||||
def validate_takeover_rules(rules: tuple[TakeoverRule, ...]) -> tuple[TakeoverRule, ...]:
|
||||
if not rules:
|
||||
raise ValueError('takeover rules cannot be empty')
|
||||
rule_ids = [rule.rule_id for rule in rules]
|
||||
if len(rule_ids) != len(set(rule_ids)):
|
||||
raise ValueError('takeover rule IDs must be unique')
|
||||
return rules
|
||||
|
||||
|
||||
TAKEOVER_RULES: tuple[TakeoverRule, ...] = (
|
||||
TakeoverRule(
|
||||
'aws-elastic-beanstalk',
|
||||
'AWS/Elastic Beanstalk',
|
||||
(r'(?:^|\.)elasticbeanstalk\.com$',),
|
||||
terminal_rcodes=('NXDOMAIN',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'aws-s3',
|
||||
'AWS/S3',
|
||||
(r'(?:^|\.)s3(?:[.-][a-z0-9-]+)?\.amazonaws\.com$',),
|
||||
body_all=('The specified bucket does not exist', 'BucketName'),
|
||||
header_none=('x-guploader-uploadid', 'aliyunoss'),
|
||||
),
|
||||
TakeoverRule(
|
||||
'airee',
|
||||
'Airee.ru',
|
||||
(r'(?:^|\.)airee\.ru$',),
|
||||
body_all=('Ошибка 402. Сервис Айри.рф не оплачен',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'anima',
|
||||
'Anima',
|
||||
(r'(?:^|\.)animaapp\.io$',),
|
||||
body_all=('The page you were looking for does not exist.',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'bitbucket',
|
||||
'Bitbucket',
|
||||
(r'(?:^|\.)bitbucket\.io$',),
|
||||
body_all=('Repository not found',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'cargo-collective',
|
||||
'Cargo Collective',
|
||||
(r'(?:^|\.)cargo\.site$',),
|
||||
classification='unverified-indicator',
|
||||
body_all=('<div class="notfound">', '404 Not Found<br>'),
|
||||
),
|
||||
TakeoverRule(
|
||||
'discourse',
|
||||
'Discourse',
|
||||
(r'(?:^|\.)trydiscourse\.com$',),
|
||||
terminal_rcodes=('NXDOMAIN',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'hatena-blog',
|
||||
'HatenaBlog',
|
||||
(r'(?:^|\.)hatenablog\.com$',),
|
||||
body_all=('404 Blog is not found',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'help-juice',
|
||||
'Help Juice',
|
||||
(r'(?:^|\.)helpjuice\.com$',),
|
||||
body_all=("We could not find what you're looking for.",),
|
||||
),
|
||||
TakeoverRule(
|
||||
'help-scout',
|
||||
'Help Scout',
|
||||
(r'(?:^|\.)helpscoutdocs\.com$',),
|
||||
body_all=('No settings were found for this company:',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'helprace',
|
||||
'Helprace',
|
||||
(r'(?:^|\.)helprace\.com$',),
|
||||
status_codes=(301,),
|
||||
),
|
||||
TakeoverRule(
|
||||
'microsoft-azure',
|
||||
'Microsoft Azure',
|
||||
(
|
||||
r'(?:^|\.)cloudapp\.net$',
|
||||
r'(?:^|\.)cloudapp\.azure\.com$',
|
||||
r'(?:^|\.)azurewebsites\.net$',
|
||||
r'(?:^|\.)blob\.core\.windows\.net$',
|
||||
r'(?:^|\.)azure-api\.net$',
|
||||
r'(?:^|\.)azurehdinsight\.net$',
|
||||
r'(?:^|\.)azureedge\.net$',
|
||||
r'(?:^|\.)azurecontainer\.io$',
|
||||
r'(?:^|\.)database\.windows\.net$',
|
||||
r'(?:^|\.)azuredatalakestore\.net$',
|
||||
r'(?:^|\.)search\.windows\.net$',
|
||||
r'(?:^|\.)azurecr\.io$',
|
||||
r'(?:^|\.)redis\.cache\.windows\.net$',
|
||||
r'(?:^|\.)servicebus\.windows\.net$',
|
||||
r'(?:^|\.)visualstudio\.com$',
|
||||
),
|
||||
terminal_rcodes=('NXDOMAIN',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'strikingly',
|
||||
'Strikingly',
|
||||
(r'(?:^|\.)s\.strikinglydns\.com$',),
|
||||
body_all=('PAGE NOT FOUND.',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'surge',
|
||||
'Surge.sh',
|
||||
(r'(?:^|\.)na-west1\.surge\.sh$',),
|
||||
body_all=('project not found',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'survey-sparrow',
|
||||
'SurveySparrow',
|
||||
(r'(?:^|\.)surveysparrow\.com$',),
|
||||
body_all=('Account not found.',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'uberflip',
|
||||
'Uberflip',
|
||||
(r'(?:^|\.)read\.uberflip\.com$',),
|
||||
body_all=("The URL you've accessed does not provide a hub.",),
|
||||
),
|
||||
TakeoverRule(
|
||||
'wordpress',
|
||||
'Wordpress',
|
||||
(r'(?:^|\.)wordpress\.com$',),
|
||||
body_all=('Do you want to register', '.wordpress.com</em> doesn’t exist'),
|
||||
body_none=('cannot be registered',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'worksites',
|
||||
'Worksites',
|
||||
(r'(?:^|\.)worksites\.net$',),
|
||||
body_all=('Hello! Sorry, but the website you’re looking for doesn’t exist.',),
|
||||
),
|
||||
TakeoverRule(
|
||||
'ghost',
|
||||
'Ghost',
|
||||
(r'(?:^|\.)ghost\.io$',),
|
||||
status_codes=(302,),
|
||||
header_any=('error.ghost.org', 'offline.ghost.org'),
|
||||
),
|
||||
TakeoverRule(
|
||||
'github-pages',
|
||||
'GitHub Pages',
|
||||
(r'(?:^|\.)github\.io$',),
|
||||
classification='edge-case',
|
||||
body_any=(
|
||||
"There isn't a GitHub Pages site here.",
|
||||
'For root URLs (like http://example.com/) you must provide an index.html file',
|
||||
'The site configured at this address does not contain the requested file.',
|
||||
'For root URLs (like <code>http://example.com/</code>)',
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
TAKEOVER_RULES = validate_takeover_rules(TAKEOVER_RULES)
|
||||
Reference in New Issue
Block a user