mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 15:21:30 +02:00
Four implementations answered "what does this host share?" and two of them could only answer it for one run: the project-wide page had no Correlation tab at all, and its detail sheet passed the row's own scan_id, so "Related" silently meant "related inside this one target's last run". One engine now, shared/services/correlation/, parameterised by QueryScope. kinds.py holds the vocabulary as data; hubs.py discovers hubs with a GROUP BY over the whole scope instead of grouping a 3,000-row sample in Python, so a hub is no longer missed for falling outside the cut. asset_query/groups.py derives its dimensions from the same registry, and correlation-graph, related and correlation take WebAssetScope. New /surface/correlation page. The count is a promise, and three bugs broke it. Asset kinds joined http_assets and counted rows, so a host answering on 80 and 443 counted twice (cert.fingerprint said 80 where the search returned 41). asn excludes CDN-fronted rows but its token did not, 8779 against 8815; KindSpec.narrows appends -is:cdn. And carriers came from a window over the group set the caller asked about, so one row's own value read as 100% of its carriers and common swallowed it. All 28 hub counts across 14 kinds now equal their searches. The provider rules moved into the engine. generic_page, shared_edge and cert_covers gated cross links alone, so estate-wide the graph drew 404 Not Found across 5 targets, 403 Forbidden across 4 and a 548-byte 404 body across 5 as correlations. A hub is judged once its members load and is labelled with who wrote it; hubs load MAX_HUBS_PER_KIND + 40 deep and re-rank. Related takes the whole vocabulary, each kind carrying the phrase it is announced with and the query its button opens, which retires relationDsl and RELATION_LABELS. asn became a kind, out of the default set. A scope over several targets opens on what crosses them: 3,000 nodes over 21,690 correlating hosts is a hairball, the 30 crossing hubs are the finding. Measured on 63,844 host rows across 10 covering scans: ~1.1s cold for the estate, 70-200ms for a scan, cached after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FwCCFgRBC8ut6SQpmzfGAa
158 lines
4.9 KiB
Python
158 lines
4.9 KiB
Python
"""The shared-identity graph for a scope: one scan, or every target's covering scan."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.services.target_names import target_names
|
|
from shared.definitions.correlation import (
|
|
CORRELATION_DEFAULT_KINDS,
|
|
CORRELATION_KIND_HELP,
|
|
CORRELATION_KIND_LABELS,
|
|
CORRELATION_KIND_ORDER,
|
|
MAX_GRAPH_HOSTS,
|
|
)
|
|
from shared.models.scan_correlation import (
|
|
CorrelationGraph,
|
|
CorrelationHost,
|
|
CorrelationHub,
|
|
CorrelationKindStat,
|
|
)
|
|
from shared.models.subdomain import Subdomain
|
|
from shared.services.asset_query.scope import QueryScope, ScopeLike
|
|
from shared.services.correlation import CorrelationFinder, Hub, Member
|
|
|
|
_HTTP_OK = 200
|
|
_HTTP_CLIENT = 400
|
|
|
|
|
|
def _rank(member: Member) -> tuple:
|
|
"""Answered first, then active, then by name."""
|
|
return (member.status is None, not member.active, member.name)
|
|
|
|
|
|
class CorrelationGraphService:
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
|
|
async def build(self, scope: ScopeLike) -> CorrelationGraph:
|
|
scope = QueryScope.of(scope)
|
|
if not scope:
|
|
return CorrelationGraph(kinds=_kinds([], {}, 0, {}))
|
|
found = await CorrelationFinder(self.session).find(scope)
|
|
estate = await self._estate(scope)
|
|
carrying: dict[UUID, Member] = {}
|
|
for hub in found.hubs:
|
|
for member in hub.members:
|
|
carrying.setdefault(member.id, member)
|
|
if not carrying:
|
|
return CorrelationGraph(
|
|
kinds=_kinds([], {}, 0, found.discovered),
|
|
estate_hosts=estate,
|
|
targets_total=len(scope.ids),
|
|
)
|
|
|
|
ranked = sorted(carrying.values(), key=_rank)
|
|
drawn = ranked[:MAX_GRAPH_HOSTS]
|
|
index = {member.id: i for i, member in enumerate(drawn)}
|
|
names = (
|
|
await target_names(self.session, (m.target_id for m in drawn))
|
|
if len(scope.ids) > 1
|
|
else {}
|
|
)
|
|
hosts = [
|
|
CorrelationHost(
|
|
id=member.id,
|
|
name=member.name,
|
|
live=member.status is not None
|
|
and _HTTP_OK <= member.status < _HTTP_CLIENT,
|
|
status=member.status,
|
|
title=member.title,
|
|
target=names.get(member.target_id, ""),
|
|
)
|
|
for member in drawn
|
|
]
|
|
|
|
hubs = [
|
|
hub
|
|
for hub in (_hub(found_hub, index) for found_hub in found.hubs)
|
|
if hub is not None
|
|
]
|
|
for hub in hubs:
|
|
for i in hub.members:
|
|
hosts[i].hubs += 1
|
|
|
|
per_kind: dict[str, int] = {}
|
|
for kind in CORRELATION_KIND_ORDER:
|
|
members = {i for hub in hubs if hub.kind == kind for i in hub.members}
|
|
if members:
|
|
per_kind[kind] = len(members)
|
|
return CorrelationGraph(
|
|
hosts=hosts,
|
|
hubs=hubs,
|
|
kinds=_kinds(hubs, per_kind, len(hosts), found.discovered),
|
|
total_hosts=len(hosts),
|
|
estate_hosts=estate,
|
|
shared_hosts=len(carrying),
|
|
targets_total=len(scope.ids),
|
|
truncated=len(carrying) > len(drawn),
|
|
)
|
|
|
|
async def _estate(self, scope: QueryScope) -> int:
|
|
return int(
|
|
await self.session.scalar(
|
|
select(func.count(Subdomain.id)).where(scope.match(Subdomain.scan_id))
|
|
)
|
|
or 0
|
|
)
|
|
|
|
|
|
def _hub(hub: Hub, index: dict[UUID, int]) -> CorrelationHub | None:
|
|
members = sorted(index[m.id] for m in hub.members if m.id in index)
|
|
if not members:
|
|
return None
|
|
return CorrelationHub(
|
|
id=hub.id,
|
|
kind=hub.kind,
|
|
value=hub.value,
|
|
label=hub.label,
|
|
count=hub.hosts,
|
|
targets=hub.targets,
|
|
share=round(hub.share, 4),
|
|
common=hub.common,
|
|
platform=bool(hub.platform),
|
|
platform_label=hub.platform,
|
|
query=hub.query,
|
|
members=members,
|
|
)
|
|
|
|
|
|
def _kinds(
|
|
hubs: list[CorrelationHub],
|
|
covered: dict[str, int],
|
|
total: int,
|
|
discovered: dict[str, int],
|
|
) -> list[CorrelationKindStat]:
|
|
drawn: dict[str, list[CorrelationHub]] = {}
|
|
for hub in hubs:
|
|
drawn.setdefault(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.get(kind, [])),
|
|
total=discovered.get(kind, 0),
|
|
common=sum(1 for h in drawn.get(kind, []) if h.common),
|
|
platform=sum(1 for h in drawn.get(kind, []) if h.platform),
|
|
crossing=sum(1 for h in drawn.get(kind, []) if h.targets > 1),
|
|
hosts=covered.get(kind, 0),
|
|
)
|
|
for kind in CORRELATION_KIND_ORDER
|
|
if total == 0 or drawn.get(kind)
|
|
]
|