Files
rengine/shared/services/ip_inventory.py
T
Yogesh OjhaandClaude Opus 5 3d310c173a fix(scans): results waited for the stage to finish, enrichment for the whole run
A running scan showed nothing. Caught live on an http_probe:
pg_stat_user_tables reported 821 rows physically inserted into
http_assets while count(*) returned 0, with one worker connection idle in
transaction for six minutes. The stage streamed httpx row by row but only
flushed inside savepoints and committed at the very end, so a 66-minute
probe was one open transaction — invisible to every reader and pinning a
connection against vacuum. Other long stages never streamed at all:
subdomain_discovery wrote nothing until every provider and the resolver
had finished, port_scan ran naabu unstreamed, url_discovery accumulated
katana's whole crawl, screenshot collected every image first. And nothing
told the browser: no rows-written event existed and no results table
refreshed while a scan ran.

stages/sink.py:ResultSink is now the one way to write incrementally —
buffer, flush on size or age, commit inside the flush, announce on its
own cadence. Stage.results_sink wires that to publish_results, which
refreshes only the affected scans counters and emits RESULTS_FOUND. Nine
stages convert. The write callback must commit; a flush that only
flushes reproduces the original bug exactly, and clearing the previous
attempt belongs inside the first flush or the table is empty for the
length of the run. nuclei needed one more thing: findings are sparse, so
_paced hands the caller back its own thread every two seconds, otherwise
a three-finding burst sits unwritten for the rest of a two-hour run.

The second half is when a derived fact is computed at all. ip_enrichment
is 10.1 s of pure SQL — 0.09% of a 3 h 08 m gov.np run — yet sat at the
last derived level, so 1,694 addresses carried a null ASN, operator and
country for 2 h 27 m, and a run cancelled before that level lost the
enrichment for good (scan d6532d88: 0 of 137 addresses, permanently). The
lookup is now one definition, ip_asn.enrich_addresses, called from
ip_inventory.materialize over the addresses it just wrote. The stage
keeps what only a finished run can know: the union sweep, CDN adoption,
the target-context fallback, the back-fills and the coverage number.

Interest is judged twice for the same reason. A saved query matches a
host on its own facts and is right at any point, so evaluate_live runs
those on a debounce while the scan works; rarity needs a finished estate,
so correlation and the model still wait for finalize. A correlation over
a tenth of the estate is not a weaker judgement, it is a wrong one.

Measured after: addresses enriched at level 2 with ip_enrichment not yet
reached, that stage down from 10.1 s to 0.1 s, http_assets ASN filled at
write time for free, 500 hosts flagged mid-discovery, and the browser's
Web assets badge climbing 2,210 -> 13,530 with no reload.

Eleven issues from an end-to-end review are folded in, four of them
regressions from the above: the rules provider emits KEYWORD but declares
RULE, so deriving the rewrite set from the class attribute dropped every
keyword signal on the full pass; a live run reported itself stale, which
made every read of the tab queue the unguarded correlation pass; SET
LOCAL lock_timeout died with ensure_builtin's commit; and enrichment
inside the insert transaction took scan-wide unordered locks that two
same-level stages could deadlock on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165iTiibhYePy1EDixwC2Vs
2026-09-08 07:43:43 +05:30

119 lines
3.5 KiB
Python

"""Every IP a scan knows about, materialised into ip_addresses and enriched in the same write."""
from __future__ import annotations
import ipaddress
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import text
from sqlalchemy.dialects.postgresql import insert
from shared.enums.ip import IpSource
from shared.models.ip_address import IpAddress
from shared.services.ip_asn import enrich_addresses
from shared.utils.datetime import utc_now
if TYPE_CHECKING:
from sqlalchemy.orm import Session
MAX_IPS = 100_000
# every table a scan can park an IP in
_COLLECT_SQL = """
SELECT ip FROM subdomains s,
LATERAL jsonb_array_elements_text(cast(s.resolved_ips AS jsonb)) ip
WHERE s.scan_id = :sid
UNION
SELECT ip FROM http_assets WHERE scan_id = :sid AND ip IS NOT NULL
UNION
SELECT ip FROM ports WHERE scan_id = :sid
UNION
SELECT ip FROM ip_addresses WHERE scan_id = :sid
"""
def collect_ips(session: Session, scan_id: uuid.UUID) -> list[str]:
rows = session.execute(text(_COLLECT_SQL).bindparams(sid=scan_id)).scalars()
seen: dict[str, None] = {}
for raw in rows:
value = (raw or "").strip()
if not value or value in seen:
continue
try:
ipaddress.ip_address(value)
except ValueError:
continue
seen[value] = None
if len(seen) >= MAX_IPS:
break
return list(seen)
def materialize(
session: Session,
*,
scan_id: uuid.UUID,
target_id: uuid.UUID,
project_id: uuid.UUID,
ips: list[str],
source: str = IpSource.DNS_RESOLUTION.value,
) -> int:
"""Insert missing ip_addresses rows. Safe to call from parallel stages."""
if not ips:
return 0
now = utc_now()
# cdn_check and passive_ports insert the same rows at the same level; a UNION has no
# guaranteed order, and two overlapping inserts taking locks in different orders deadlock
ips = sorted(set(ips))
rows = [
{
"id": uuid.uuid4(),
"scan_id": scan_id,
"target_id": target_id,
"project_id": project_id,
"ip": ip,
"version": ipaddress.ip_address(ip).version,
"source": source,
"ptr_hostnames": [],
"is_cdn": False,
"discovered_at": now,
"created_at": now,
}
for ip in ips
]
statement = (
insert(IpAddress)
.values(rows)
.on_conflict_do_nothing(constraint="uq_ipaddress_scan_ip")
)
result = session.execute(statement)
session.commit()
# ASN, operator and country are a pure function of the address and a local table, so an
# address is never stored without them; the enrichment stage is a sweep, not the source.
# It runs after the insert commits and only over these addresses: parallel stages at the
# same level both call ensure(), and one long transaction holding unordered update locks
# across the whole scan is how two overlapping address sets deadlock.
enrich_addresses(session, scan_id=scan_id, ips=ips)
session.commit()
return int(result.rowcount or 0)
def ensure(
session: Session,
*,
scan_id: uuid.UUID,
target_id: uuid.UUID,
project_id: uuid.UUID,
) -> list[str]:
"""Collect every known IP and guarantee a row for each. Returns the address list."""
ips = collect_ips(session, scan_id)
materialize(
session,
scan_id=scan_id,
target_id=target_id,
project_id=project_id,
ips=ips,
)
return ips