Files
rengine/api/app/services/target_relations.py
T
Yogesh OjhaandClaude Opus 5 0b853e04b6 chore: production-readiness review across the tree
A full static review of the 3.0 tree, run as two fan-out passes with every
finding verified against the running stack rather than taken on inspection.

Correctness and security
- instance settings encrypted the mask over the stored AI key: `!= MASK` never
  matched `••••••••abcd`, so saving settings destroyed the key and `test_ai`
  posted the mask as a bearer token
- the MFA token was replayable for its full five-minute life; the gate now
  checks revocation and consumes the token after a successful exchange
- logout revoked nothing for a bearer client while answering "Logged out"
- `POST /api-keys/{id}/test` returned the plaintext key inside httpx's error
  string, which embeds the whole URL
- an interest rule could be edited or deleted through another project's id
- redis ran the celery broker under `allkeys-lru`, free to evict a queued task
  or a chord counter; now `noeviction`
- nuclei's stored command carried the OAST token and every auth header
- a finalize step could poison the session and leave a scan RUNNING for ever
- `is:issue` raised TypeError on every search
- offset paging repeated and skipped rows in four dimensions
- a cancelled run kept a stale `scans.*_found` rollup
- every PDF report raised `KeyError: 'software'`
- `version_key` truncated a wide numeric run and inverted release ordering
- psycopg raises a NUL as a bare ValueError, above StatementError, so every
  savepoint guard was blind to the case it was written for

Performance
- endpoints.host had no trigram index: 35.9ms seq scan -> 0.389ms
- free text compiled to one OR, not the prescribed UNION ALL: 132ms -> 41ms
- `/http-assets/summary` and `/ips/summary` materialised whole tables
- the cross-link `ip` kind ran an estate query per page and drew nothing

Frontend
- `$effect` loaders that read the state they write, an unbounded request loop
  on a failing endpoint
- error states that rendered as empty states
- 2FA backup codes were unusable while the copy said they worked
- a "Scan history" dialog that always reported none
- ~400 duplicated lines across five filter bars, now one component

Build and tooling
- every scanner installed at @latest; all 13 pinned to the measured versions
- CI built `web/`, gone since 2.x, with tags that cannot be valid
- the prettier pre-commit hook discarded its filenames and always passed
- the test suite destroyed itself when run twice

Also: subdomains from public code, via a pinned github-subdomains source and a
GitHub API key that subfinder receives too; sublist3r and oneforall removed
after measuring one unique host in 89 across four targets.

Verified: 853 tests, ruff clean over eleven packages, svelte-check 0 errors,
the count-is-a-promise gate at 131 checks and 0 mismatches, and 143 GET routes
with no 5xx. Four migrations, each with an exercised downgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVkdx5ynZ6aof5kLFczmhX
2026-09-13 18:03:29 +05:30

319 lines
12 KiB
Python

"""What shows that two targets in one project are the same estate."""
from __future__ import annotations
import re
from collections import defaultdict
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.surface_scope import SurfaceScopeService
from shared.definitions.domains import registrable_domain
from shared.definitions.relations import (
FAVICON_MAX_TARGETS,
MAX_RELATED_TARGETS,
MAX_RELATION_EVIDENCE,
MAX_SAN_ROWS,
RELATION_LABELS,
RELATION_ORDER,
TargetRelation,
)
from shared.definitions.surface import SurfaceDimension
from shared.models.dns import DnsRecord
from shared.models.http_asset import HttpAsset
from shared.models.ip_address import IpAddress
from shared.models.relations import RelatedTarget, RelationEvidence, TargetRelations
from shared.models.subdomain import Subdomain
from shared.models.target import Target
from shared.models.whois import WhoisNameserver, WhoisRecord
from shared.services.asset_query.scope import QueryScope
from shared.utils.infra import is_shared_host, is_shared_nameserver, owns_network
from shared.utils.privacy import registrant_key
# kind -> value -> targets carrying it, with what to show for each
_Facts = dict[str, dict[str, dict[UUID, str]]]
_SPF_INCLUDE = re.compile(r"include:([^\s]+)")
_ADDRESS_RECORDS = ("A", "AAAA")
def _ours(host: str, owned: set[str]) -> bool:
"""A mail host or a zone contact identifies an estate only when the estate owns it."""
return bool(host) and not is_shared_host(host) and registrable_domain(host) in owned
def _dns_keys(
record_type: str, value: str, soa_email: str, cdn: set[str], owned: set[str]
) -> list[tuple[str, str]]:
"""What a record says about ownership, as (shown, key) pairs. NS is the WHOIS relation's."""
kind = record_type.upper().removeprefix("DNSRECORDTYPE.")
host = value.strip().lower().rstrip(".")
if kind == "MX" and _ours(host, owned):
return [(f"MX {host}", f"mx:{host}")]
if kind == "SOA":
contact = soa_email.strip().lower().rstrip(".")
if _ours(contact, owned):
return [(f"SOA {contact}", f"soa:{contact}")]
return []
if kind in _ADDRESS_RECORDS and host and host not in cdn:
return [(f"{kind} {host}", f"address:{host}")]
if kind == "TXT" and host.startswith("v=spf1"):
return [
(f"SPF include:{domain}", f"spf:{domain}")
for domain in {
m.group(1).lower().rstrip(".") for m in _SPF_INCLUDE.finditer(host)
}
if _ours(domain, owned)
]
return []
def _identity(target_value: str, registrant: str) -> set[str]:
"""The names a network holder would carry if this target ran it."""
apex = registrable_domain(target_value) or target_value
label = apex.split(".")[0] if apex else ""
return {key for key in (registrant_key(registrant), label.lower()) if key}
class TargetRelationService:
def __init__(self, session: AsyncSession):
self.session = session
async def for_target(self, project_id: UUID, target_id: UUID) -> TargetRelations:
targets = await self._targets(project_id)
if target_id not in targets or len(targets) < 2: # noqa: PLR2004
return TargetRelations(considered=len(targets))
facts = await self._facts(project_id, targets)
reasons: dict[UUID, list[RelationEvidence]] = defaultdict(list)
for kind in RELATION_ORDER:
for value, carriers in facts.get(kind, {}).items():
if target_id not in carriers:
continue
for other, detail in carriers.items():
if other == target_id or other not in targets:
continue
reasons[other].append(
RelationEvidence(
kind=kind,
label=RELATION_LABELS[kind],
value=value,
detail=detail,
)
)
items = [
RelatedTarget(
target_id=other,
target_value=targets[other].target_value,
reasons=evidence[:MAX_RELATION_EVIDENCE],
)
for other, evidence in reasons.items()
]
items.sort(
key=lambda item: (
RELATION_ORDER.index(item.reasons[0].kind),
-len(item.reasons),
item.target_value,
)
)
return TargetRelations(
items=items[:MAX_RELATED_TARGETS],
total=len(items),
considered=len(targets),
)
async def _targets(self, project_id: UUID) -> dict[UUID, Target]:
rows = await self.session.execute(
select(Target).where(Target.project_id == project_id)
)
return {row.id: row for row in rows.scalars().all()}
async def _facts(self, project_id: UUID, targets: dict[UUID, Target]) -> _Facts:
"""Each dimension is read from the scans that covered it."""
scopes = SurfaceScopeService(self.session)
assets = await scopes.scope(project_id, SurfaceDimension.WEB_ASSETS.value)
addresses = await scopes.scope(project_id, SurfaceDimension.IPS.value)
facts: _Facts = defaultdict(lambda: defaultdict(dict))
registrants = await self._registrants(targets)
await self._registration(targets, registrants, facts)
await self._dns(targets, facts)
if assets:
await self._certificates(assets, targets, facts)
await self._favicons(assets, facts)
if addresses:
await self._networks(addresses, targets, registrants, facts)
return facts
async def _registration(
self, targets: dict[UUID, Target], registrants: dict[UUID, str], facts: _Facts
) -> None:
for target_id, registrant in registrants.items():
key = registrant_key(registrant)
if key:
facts[TargetRelation.REGISTRANT.value][key][target_id] = registrant
ids = [t.whois_record_id for t in targets.values() if t.whois_record_id]
if not ids:
return
by_record = {t.whois_record_id: t.id for t in targets.values()}
names = await self.session.execute(
select(WhoisNameserver.whois_record_id, WhoisNameserver.nameserver).where(
WhoisNameserver.whois_record_id.in_(ids)
)
)
for record_id, nameserver in names.all():
if is_shared_nameserver(nameserver):
continue
facts[TargetRelation.NAMESERVER.value][nameserver][by_record[record_id]] = (
nameserver
)
async def _dns(self, targets: dict[UUID, Target], facts: _Facts) -> None:
"""A mail host, a zone contact or an address that is not a provider's."""
rows = (
await self.session.execute(
select(
DnsRecord.target_id,
DnsRecord.record_type,
DnsRecord.value,
DnsRecord.soa_email,
).where(DnsRecord.target_id.in_(list(targets)))
)
).all()
cdn = await self._cdn_addresses(
{(value or "").strip().lower().rstrip(".") for _, _, value, _ in rows}
)
owned = {
registrable_domain(t.target_value)
for t in targets.values()
if registrable_domain(t.target_value)
}
for target_id, kind, value, soa_email in rows:
for shown, key in _dns_keys(
str(kind), value or "", soa_email or "", cdn, owned
):
facts[TargetRelation.DNS_RECORD.value][key][target_id] = shown
async def _cdn_addresses(self, candidates: set[str]) -> set[str]:
"""The given values that are a provider's edge address."""
if not candidates:
return set()
rows = await self.session.execute(
select(IpAddress.ip)
.where(IpAddress.is_cdn.is_(True), IpAddress.ip.in_(candidates))
.distinct()
)
return set(rows.scalars().all())
async def _certificates(
self, scope: QueryScope, targets: dict[UUID, Target], facts: _Facts
) -> None:
rows = await self.session.execute(
select(
HttpAsset.target_id,
HttpAsset.tls_fingerprint,
func.min(HttpAsset.tls_subject_cn),
)
.where(
scope.match(HttpAsset.scan_id), HttpAsset.tls_fingerprint.isnot(None)
)
.group_by(HttpAsset.target_id, HttpAsset.tls_fingerprint)
)
for target_id, fingerprint, subject in rows.all():
facts[TargetRelation.CERTIFICATE.value][fingerprint][target_id] = (
subject or ""
)
apexes = {
registrable_domain(t.target_value): t.id
for t in targets.values()
if registrable_domain(t.target_value)
}
sans = await self.session.execute(
select(HttpAsset.target_id, HttpAsset.tls_sans, HttpAsset.host)
.where(
scope.match(HttpAsset.scan_id), HttpAsset.tls_fingerprint.isnot(None)
)
.limit(MAX_SAN_ROWS)
)
for target_id, names, host in sans.all():
for raw in names or []:
apex = registrable_domain(str(raw))
owner = apexes.get(apex)
if owner is None or owner == target_id:
continue
facts[TargetRelation.CERTIFICATE.value][f"san:{apex}"][target_id] = host
facts[TargetRelation.CERTIFICATE.value][f"san:{apex}"][owner] = apex
async def _favicons(self, scope: QueryScope, facts: _Facts) -> None:
rows = await self.session.execute(
select(Subdomain.favicon_hash, Subdomain.target_id, func.count())
.where(
scope.match(Subdomain.scan_id),
Subdomain.favicon_hash.isnot(None),
Subdomain.favicon_hash != "",
)
.group_by(Subdomain.favicon_hash, Subdomain.target_id)
)
carried: dict[str, dict[UUID, str]] = defaultdict(dict)
for value, target_id, count in rows.all():
carried[value][target_id] = f"{count} web assets"
for value, owners in carried.items():
if len(owners) <= FAVICON_MAX_TARGETS:
facts[TargetRelation.FAVICON.value][value] = owners
async def _networks(
self,
scope: QueryScope,
targets: dict[UUID, Target],
registrants: dict[UUID, str],
facts: _Facts,
) -> None:
rows = await self.session.execute(
select(
IpAddress.asn,
func.min(IpAddress.asn_org),
IpAddress.target_id,
func.count(),
)
.where(scope.match(IpAddress.scan_id), IpAddress.asn.isnot(None))
.group_by(IpAddress.asn, IpAddress.target_id)
)
holders: dict[int, str] = {}
carried: dict[int, dict[UUID, str]] = defaultdict(dict)
for asn, org, target_id, count in rows.all():
holders[asn] = org or ""
carried[asn][target_id] = f"{count} addresses"
for asn, owners in carried.items():
identities: set[str] = set()
for target_id in owners:
target = targets.get(target_id)
if target is not None:
identities |= _identity(
target.target_value, registrants.get(target_id, "")
)
if not owns_network(holders[asn], identities):
continue
value = f"AS{asn}"
for target_id, detail in owners.items():
facts[TargetRelation.NETWORK.value][value][target_id] = (
f"{holders[asn]} · {detail}" if holders[asn] else detail
)
async def _registrants(self, targets: dict[UUID, Target]) -> dict[UUID, str]:
ids = [t.whois_record_id for t in targets.values() if t.whois_record_id]
if not ids:
return {}
by_record = {t.whois_record_id: t.id for t in targets.values()}
rows = await self.session.execute(
select(WhoisRecord.id, WhoisRecord.registrant_name).where(
WhoisRecord.id.in_(ids)
)
)
return {by_record[rid]: name for rid, name in rows.all()}