Files
rengine/api/app/services/correlation_graph.py
T
Yogesh OjhaandClaude Opus 5 2cb8d73280 style: strip explanatory comments across the codebase
The comment rule in CLAUDE.md is one line maximum, never narration: no
multi-line blocks, no module-header explainers, no sentences that explain or
justify the code to the reader. The tree had drifted a long way from it, and
most of the drift is mine.

Removed:
  232  lines of multi-line comment blocks
  203  single-line comments that argued instead of stating
  154  comments joined by so/because/otherwise/rather-than
  143  narrating comments in the frontend
  112  test docstrings (the test name already says what it asserts)
   25  test module docstrings and 25 decorative section dividers

Collapsed:
  324  multi-line docstrings to one factual line
  271  one-line docstrings that had a justifying clause bolted on

Kept: functional directives, section dividers, short field labels, alembic
revision headers, and the eight FastAPI route docstrings that render in /docs.
Where a fact was load-bearing it is in CLAUDE.md, which is where it belongs.

284 python tests and 34 frontend tests pass, ruff and ruff-format clean across
ten packages, svelte-check 0 errors, prettier and eslint clean, app.main imports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013dKUV96WkQQETZBMoAG98C
2026-09-11 14:11:23 +05:30

212 lines
7.2 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 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_SHARED,
CorrelationKind,
)
from shared.models.http_asset import HttpAsset
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
_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 _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]}…"
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.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))
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,
*[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
for kind, (attr, _op) in _ASSET_KINDS.items():
for value in _values(getattr(asset, attr)):
members[kind][value].add(i)
total = len(hosts)
hubs: list[CorrelationHub] = []
per_kind_hubs: dict[str, int] = defaultdict(int)
per_kind_common: dict[str, int] = defaultdict(int)
per_kind_hosts: dict[str, set[int]] = defaultdict(set)
ops = {**_HOST_KINDS, **_ASSET_KINDS}
for kind in CORRELATION_KIND_ORDER:
shared = [
(value, sorted(idx))
for value, idx in members.get(kind, {}).items()
if len(idx) >= MIN_SHARED
]
shared.sort(key=lambda x: (-len(x[1]), x[0]))
for value, idx in shared[:MAX_HUBS_PER_KIND]:
share = len(idx) / total
common = share >= COMMON_SHARE
per_kind_hubs[kind] += 1
if common:
per_kind_common[kind] += 1
per_kind_hosts[kind].update(idx)
hubs.append(
CorrelationHub(
id=f"{kind}:{value}",
kind=kind,
value=value,
label=_label(kind, value),
count=len(idx),
share=round(share, 4),
common=common,
query=group_token(kind, ops[kind][1], value),
members=idx,
)
)
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(
{k: (per_kind_hubs[k], per_kind_common[k]) for k in per_kind_hubs},
{k: len(v) for k, v in per_kind_hosts.items()},
total,
),
total_hosts=total,
shared_hosts=sum(1 for h in hosts if h.hubs),
truncated=truncated,
)
@staticmethod
def _kinds(
counts: dict[str, tuple[int, int]], covered: dict[str, int], total: int
) -> list[CorrelationKindStat]:
return [
CorrelationKindStat(
key=kind,
label=CORRELATION_KIND_LABELS[kind],
help=CORRELATION_KIND_HELP[kind],
default=kind in CORRELATION_DEFAULT_KINDS,
hubs=counts.get(kind, (0, 0))[0],
common=counts.get(kind, (0, 0))[1],
hosts=covered.get(kind, 0),
)
for kind in CORRELATION_KIND_ORDER
if total == 0 or counts.get(kind, (0, 0))[0] > 0
]