mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 15:21:30 +02:00
Four scans against real estates (hackerone.com 40 names, bugcrowd.com 71, uber.com 11,203, shopify.com 26,235) show the correlation graph drawing the CDN's identities and the resolver reporting a fraction of the live surface as all of it. The resolver's silence is re-queried once. dnsx prints nothing both for a name that does not exist and for a query it dropped, and the loss is uniform, so `_retry_degraded`'s peer median never fires (and a single-batch estate has no peers). uber.com reported 135 of 11,203 names resolved; a plain dnsx re-run over a random 200 of the 11,068 it called dead answered 7, the same 7 three times over, and a DoH check of another 25 found 2 more. `_retry_silent` re-queries every unanswered name once (`dns_retry_silent`), writes through `_write_resolution` so the live view keeps up, counts recoveries as `recovered`, and reports partial when that pass still recovers names. A hub that belongs to a provider is not a correlation. bugcrowd.com drew six hubs and five of them were the CDN's: four Fastly anycast addresses and the `j.sni.global.fastly.net` CNAME every Fastly tenant is aliased to - four hubs over one pair of hosts, each restating what the CDN kind already says. Hubs now carry `platform`/`platform_label` from `ip_addresses.is_cdn` and from curated lists beside `is_shared_nameserver` (`SHARED_EDGE_HOSTS`, `PUBLIC_CA_NAMES`), the graph hides them behind "Hide provider", and `related()` drops them. Curated, never inferred from a suffix: `d3tqr1od3k1eh2.cloudfront.net` is one distribution, `j.sni.global.fastly.net` is the whole platform; a private issuer stays visible. Three more corrections in the same build. A share is read against the hosts carrying that kind, not every name in the scan, so shopify.com's 16-of-18 JARM cluster no longer reads as 0.5%, and `common` needs `MIN_ESTATE_FOR_COMMON` carriers. The 3,000-host cut is ranked by "answered, then resolved, then name" - alphabetically it held 19 of shopify.com's 116 web assets, and ranking took that scan from 35 hubs to 94 and its certificate correlation from 6 hosts to 66. A body under `MIN_BODY_BYTES` is not an identity: 11 hosts shared the SHA-256 of an empty body. Hidden hubs no longer consume the per-kind cap, chip counts equal what they draw, and the endpoint goes through `lead_cache` like its neighbours (3.3s to 18ms). Enrichment, which feeds all of it: - `sync_ranges` takes an advisory lock and a budget. Two concurrent scans on a fresh instance each pulled the same 200 MB and each truncated and reloaded 1.1M rows: 291s against 145s. A scan that finds the tables missing reports partial, and `app.tasks.ip_asn.refresh` fills the blanks through `backfill_addresses` once the feed lands. - No address is attributed from its target. The last-resort fill stamped the target's BGP holder and its WHOIS registrant country onto every address the range tables did not match, private ones included - a registrant country is not a geolocation and a target's network is not the operator of everything its scan found. It fired wherever that data exists, which is IP, range and ASN targets and any registry that publishes a country; only an ASN target now states its own sweep. - cdncheck sees every address, httpx only the ones that answered, so `ip_enrichment` folds `ip_addresses.is_cdn` back onto the hosts that resolve to it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YYop31s6BqDUt4TBs5mbwa
276 lines
9.3 KiB
Python
276 lines
9.3 KiB
Python
"""Shared identities across a scan's hosts, keyed by the Web Assets group-dimension columns."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from shared.definitions.correlation import (
|
|
COMMON_SHARE,
|
|
CORRELATION_DEFAULT_KINDS,
|
|
CORRELATION_KIND_HELP,
|
|
CORRELATION_KIND_LABELS,
|
|
CORRELATION_KIND_ORDER,
|
|
MAX_GRAPH_HOSTS,
|
|
MAX_HUBS_PER_KIND,
|
|
MIN_BODY_BYTES,
|
|
MIN_ESTATE_FOR_COMMON,
|
|
MIN_SHARED,
|
|
CorrelationKind,
|
|
)
|
|
from shared.models.http_asset import HttpAsset
|
|
from shared.models.ip_address import IpAddress
|
|
from shared.models.scan_correlation import (
|
|
CorrelationGraph,
|
|
CorrelationHost,
|
|
CorrelationHub,
|
|
CorrelationKindStat,
|
|
)
|
|
from shared.models.subdomain import Subdomain
|
|
from shared.services.asset_query.groups import group_token
|
|
from shared.utils.infra import public_ca, shared_edge
|
|
|
|
_HTTP_OK = 200
|
|
_HTTP_CLIENT = 400
|
|
_LABEL_MAX = 60
|
|
_HASH_LABEL = 14
|
|
|
|
# source column and token operator per kind
|
|
_HOST_KINDS: dict[str, tuple[str, str]] = {
|
|
CorrelationKind.IP.value: ("resolved_ips", ":"),
|
|
CorrelationKind.CNAME.value: ("cname", "="),
|
|
CorrelationKind.TITLE.value: ("page_title", "="),
|
|
CorrelationKind.FAVICON.value: ("favicon_hash", "="),
|
|
CorrelationKind.TECH.value: ("tech", "="),
|
|
CorrelationKind.SERVER.value: ("webserver", "="),
|
|
CorrelationKind.CDN.value: ("cdn_name", "="),
|
|
}
|
|
_ASSET_KINDS: dict[str, tuple[str, str]] = {
|
|
CorrelationKind.BODY.value: ("content_hash", "="),
|
|
CorrelationKind.JARM.value: ("jarm", "="),
|
|
CorrelationKind.CERT.value: ("tls_fingerprint", "="),
|
|
CorrelationKind.CERT_ISSUER.value: ("tls_issuer", "="),
|
|
CorrelationKind.HEADERS.value: ("header_hash", "="),
|
|
}
|
|
|
|
|
|
def _values(raw) -> list[str]:
|
|
if raw is None:
|
|
return []
|
|
if isinstance(raw, list):
|
|
return [str(v) for v in raw if v not in (None, "")]
|
|
text = str(raw)
|
|
return [text] if text else []
|
|
|
|
|
|
def _platform(kind: str, value: str, cdn_addresses: dict[str, str]) -> str:
|
|
"""The provider whose tenants all carry this value."""
|
|
if kind == CorrelationKind.IP.value:
|
|
return cdn_addresses.get(value, "")
|
|
if kind == CorrelationKind.CNAME.value:
|
|
return shared_edge(value) or ""
|
|
if kind == CorrelationKind.CERT_ISSUER.value:
|
|
return public_ca(value) or ""
|
|
return ""
|
|
|
|
|
|
def _label(kind: str, value: str) -> str:
|
|
# keep both ends of a hash
|
|
if kind in (
|
|
CorrelationKind.BODY.value,
|
|
CorrelationKind.JARM.value,
|
|
CorrelationKind.FAVICON.value,
|
|
):
|
|
return value if len(value) <= _HASH_LABEL else f"{value[:8]}…{value[-4:]}"
|
|
return value if len(value) <= _LABEL_MAX else f"{value[: _LABEL_MAX - 1]}…"
|
|
|
|
|
|
def _hubs_of_kind(
|
|
kind: str, values: dict[str, set[int]], cdn_addresses: dict[str, str]
|
|
) -> list[CorrelationHub]:
|
|
"""The hubs one kind contributes, most telling first."""
|
|
# denominator: the hosts carrying this kind
|
|
carriers = len({i for idx in values.values() for i in idx})
|
|
candidates = []
|
|
for value, idx in values.items():
|
|
if len(idx) < MIN_SHARED:
|
|
continue
|
|
share = len(idx) / carriers if carriers else 0.0
|
|
candidates.append(
|
|
(
|
|
value,
|
|
sorted(idx),
|
|
share,
|
|
carriers >= MIN_ESTATE_FOR_COMMON and share >= COMMON_SHARE,
|
|
_platform(kind, value, cdn_addresses),
|
|
)
|
|
)
|
|
candidates.sort(key=lambda c: (bool(c[4]), c[3], -len(c[1]), c[0]))
|
|
operator = {**_HOST_KINDS, **_ASSET_KINDS}[kind][1]
|
|
return [
|
|
CorrelationHub(
|
|
id=f"{kind}:{value}",
|
|
kind=kind,
|
|
value=value,
|
|
label=_label(kind, value),
|
|
count=len(idx),
|
|
share=round(share, 4),
|
|
common=common,
|
|
platform=bool(platform),
|
|
platform_label=platform,
|
|
query=group_token(kind, operator, value),
|
|
members=idx,
|
|
)
|
|
for value, idx, share, common, platform in candidates[:MAX_HUBS_PER_KIND]
|
|
]
|
|
|
|
|
|
class CorrelationGraphService:
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
|
|
async def build(self, project_id: UUID, scan_id: UUID) -> CorrelationGraph:
|
|
rows = (
|
|
await self.session.execute(
|
|
select(
|
|
Subdomain.id,
|
|
Subdomain.name,
|
|
Subdomain.is_active,
|
|
Subdomain.http_status,
|
|
Subdomain.page_title,
|
|
Subdomain.resolved_ips,
|
|
Subdomain.cname,
|
|
Subdomain.favicon_hash,
|
|
Subdomain.tech,
|
|
Subdomain.webserver,
|
|
Subdomain.cdn_name,
|
|
)
|
|
.where(Subdomain.project_id == project_id, Subdomain.scan_id == scan_id)
|
|
.order_by(
|
|
Subdomain.http_status.is_(None),
|
|
Subdomain.is_active.is_(False),
|
|
Subdomain.name,
|
|
)
|
|
.limit(MAX_GRAPH_HOSTS + 1)
|
|
)
|
|
).all()
|
|
truncated = len(rows) > MAX_GRAPH_HOSTS
|
|
rows = rows[:MAX_GRAPH_HOSTS]
|
|
if not rows:
|
|
return CorrelationGraph(kinds=self._kinds([], {}, 0))
|
|
estate = await self._estate(project_id, scan_id) if truncated else len(rows)
|
|
|
|
index = {row.name: i for i, row in enumerate(rows)}
|
|
hosts = [
|
|
CorrelationHost(
|
|
id=row.id,
|
|
name=row.name,
|
|
live=row.http_status is not None
|
|
and _HTTP_OK <= row.http_status < _HTTP_CLIENT,
|
|
status=row.http_status,
|
|
title=row.page_title,
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
members: dict[str, dict[str, set[int]]] = defaultdict(lambda: defaultdict(set))
|
|
for i, row in enumerate(rows):
|
|
for kind, (attr, _op) in _HOST_KINDS.items():
|
|
for value in _values(getattr(row, attr)):
|
|
members[kind][value].add(i)
|
|
|
|
assets = (
|
|
await self.session.execute(
|
|
select(
|
|
HttpAsset.host,
|
|
HttpAsset.content_length,
|
|
*[getattr(HttpAsset, attr) for attr, _op in _ASSET_KINDS.values()],
|
|
).where(HttpAsset.scan_id == scan_id)
|
|
)
|
|
).all()
|
|
for asset in assets:
|
|
i = index.get(asset.host)
|
|
if i is None:
|
|
continue
|
|
thin = (asset.content_length or 0) < MIN_BODY_BYTES
|
|
for kind, (attr, _op) in _ASSET_KINDS.items():
|
|
if kind == CorrelationKind.BODY.value and thin:
|
|
continue
|
|
for value in _values(getattr(asset, attr)):
|
|
members[kind][value].add(i)
|
|
|
|
total = len(hosts)
|
|
cdn_addresses = await self._cdn_addresses(scan_id)
|
|
hubs: list[CorrelationHub] = []
|
|
per_kind_hosts: dict[str, set[int]] = defaultdict(set)
|
|
for kind in CORRELATION_KIND_ORDER:
|
|
for hub in _hubs_of_kind(kind, members.get(kind, {}), cdn_addresses):
|
|
hubs.append(hub)
|
|
per_kind_hosts[kind].update(hub.members)
|
|
|
|
degree: dict[int, int] = defaultdict(int)
|
|
for hub in hubs:
|
|
for i in hub.members:
|
|
degree[i] += 1
|
|
for i, host in enumerate(hosts):
|
|
host.hubs = degree.get(i, 0)
|
|
|
|
return CorrelationGraph(
|
|
hosts=hosts,
|
|
hubs=hubs,
|
|
kinds=self._kinds(
|
|
hubs,
|
|
{k: len(v) for k, v in per_kind_hosts.items()},
|
|
total,
|
|
),
|
|
total_hosts=total,
|
|
estate_hosts=estate,
|
|
shared_hosts=sum(1 for h in hosts if h.hubs),
|
|
truncated=truncated,
|
|
)
|
|
|
|
async def _estate(self, project_id: UUID, scan_id: UUID) -> int:
|
|
return int(
|
|
await self.session.scalar(
|
|
select(func.count(Subdomain.id)).where(
|
|
Subdomain.project_id == project_id, Subdomain.scan_id == scan_id
|
|
)
|
|
)
|
|
or 0
|
|
)
|
|
|
|
async def _cdn_addresses(self, scan_id: UUID) -> dict[str, str]:
|
|
rows = (
|
|
await self.session.execute(
|
|
select(IpAddress.ip, IpAddress.cdn_name).where(
|
|
IpAddress.scan_id == scan_id, IpAddress.is_cdn.is_(True)
|
|
)
|
|
)
|
|
).all()
|
|
return {ip: name or "CDN" for ip, name in rows}
|
|
|
|
@staticmethod
|
|
def _kinds(
|
|
hubs: list[CorrelationHub], covered: dict[str, int], total: int
|
|
) -> list[CorrelationKindStat]:
|
|
drawn: dict[str, list[CorrelationHub]] = defaultdict(list)
|
|
for hub in hubs:
|
|
drawn[hub.kind].append(hub)
|
|
return [
|
|
CorrelationKindStat(
|
|
key=kind,
|
|
label=CORRELATION_KIND_LABELS[kind],
|
|
help=CORRELATION_KIND_HELP[kind],
|
|
default=kind in CORRELATION_DEFAULT_KINDS,
|
|
hubs=len(drawn[kind]),
|
|
common=sum(1 for h in drawn[kind] if h.common),
|
|
platform=sum(1 for h in drawn[kind] if h.platform),
|
|
hosts=covered.get(kind, 0),
|
|
)
|
|
for kind in CORRELATION_KIND_ORDER
|
|
if total == 0 or drawn[kind]
|
|
]
|