Files
rengine/tools/httpx/client.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

163 lines
5.2 KiB
Python

from __future__ import annotations
import contextlib
from collections.abc import Iterator
from shared.logging import get_logger
from tools.runner import CLIToolRunner, StreamOutcome, ToolNotFoundError
from tools.runner.models import CommandRecorder
logger = get_logger(__name__)
HTTPX_BINARY = "httpx"
DEFAULT_TIMEOUT = 900
# a probe that keeps answering keeps running; only a stalled one is killed
_IDLE_FLOOR = 120
_IDLE_TIMEOUT_FACTOR = 6
_CAPTURE_IDLE_FLOOR = 300
# rendering budget: generous per target, hard-capped, never unbounded
CAPTURE_SECONDS_PER_TARGET = 6
MAX_CAPTURE_SECONDS = 7200
# cap response-body read to bound DB growth + worker memory (per-record, times N hosts)
_RESPONSE_SIZE_CAP = 131072 # 128 KiB
_ENRICH_FLAGS = [
"-status-code",
"-title",
"-tech-detect",
"-web-server",
"-content-length",
"-content-type",
"-location",
"-ip",
"-cname",
"-cdn",
"-tls-grab",
"-jarm",
"-favicon",
"-hash",
"sha256",
"-http2",
"-pipeline",
"-include-response",
"-body-preview",
"-response-size-to-read",
str(_RESPONSE_SIZE_CAP),
"-no-color",
]
class HttpxError(Exception):
pass
class HttpxClient:
def __init__(
self,
*,
rate_limit: int | None = None,
threads: int = 50,
timeout: int = 10,
proxy_url: str | None = None,
headers: dict[str, str] | None = None,
follow_redirects: bool = True,
store_dir: str | None = None,
recorder: CommandRecorder | None = None,
extra_args: list[str] | None = None,
) -> None:
self.rate_limit = rate_limit
self.threads = threads
self.timeout = timeout
self.proxy_url = proxy_url
self.headers = headers or {}
self.follow_redirects = follow_redirects
self.store_dir = store_dir
self.recorder = recorder
self.extra_args = extra_args or []
try:
self._runner = CLIToolRunner(HTTPX_BINARY, default_timeout=DEFAULT_TIMEOUT)
except ToolNotFoundError as e:
raise HttpxError(str(e)) from e
@contextlib.contextmanager
def stream_probe(self, targets: list[str]) -> Iterator[StreamOutcome]:
"""Probe targets, streaming parsed httpx records one at a time (memory-bounded)."""
if not targets:
yield StreamOutcome(records=iter(()), return_code=0)
return
args = list(_ENRICH_FLAGS)
if self.follow_redirects:
args.append("-follow-redirects")
if self.rate_limit:
args += ["-rate-limit", str(self.rate_limit)]
args += ["-threads", str(self.threads), "-timeout", str(self.timeout)]
if self.proxy_url:
args += ["-proxy", self.proxy_url]
for key, value in self.headers.items():
args += ["-header", f"{key}: {value}"]
with self._runner.stream_json(
args=args,
input_data=targets,
input_flag="-l",
json_flag="-json",
silent=True,
silent_flag="-silent",
timeout=0,
idle_timeout=max(_IDLE_FLOOR, self.timeout * _IDLE_TIMEOUT_FACTOR),
recorder=self.recorder,
tool=HTTPX_BINARY,
extra_args=self.extra_args,
) as stream:
yield stream
def _capture_args(self) -> list[str]:
args = [
"-status-code",
"-screenshot",
"-system-chrome",
"-exclude-screenshot-bytes",
"-no-color",
]
if self.store_dir:
args += ["-store-response-dir", self.store_dir]
if self.follow_redirects:
args.append("-follow-redirects")
args += ["-threads", str(self.threads), "-timeout", str(max(self.timeout, 20))]
if self.proxy_url:
args += ["-proxy", self.proxy_url]
for key, value in self.headers.items():
args += ["-header", f"{key}: {value}"]
return args
@contextlib.contextmanager
def stream_capture(self, targets: list[str]) -> Iterator[StreamOutcome]:
"""The same render, streaming each image as the browser finishes it."""
if not targets:
yield StreamOutcome(records=iter(()), return_code=0)
return
# a total ceiling as well as the idle watchdog: a renderer that keeps emitting one
# image every few minutes is not stalled, but it must not run to the celery limit
ceiling = min(
MAX_CAPTURE_SECONDS,
max(DEFAULT_TIMEOUT, len(targets) * CAPTURE_SECONDS_PER_TARGET),
)
with self._runner.stream_json(
args=self._capture_args(),
input_data=targets,
input_flag="-l",
json_flag="-json",
silent=True,
silent_flag="-silent",
timeout=ceiling,
# a headless browser is slow to start and slow per page, so the watchdog
# has to be generous — it is only there for a renderer that has died
idle_timeout=max(_CAPTURE_IDLE_FLOOR, self.timeout * _IDLE_TIMEOUT_FACTOR),
recorder=self.recorder,
tool=HTTPX_BINARY,
extra_args=self.extra_args,
) as stream:
yield stream