Files
rengine/tools/httpx/client.py
T
Yogesh OjhaandClaude Opus 5 05be5301d4 perf(scans): the pipeline runs its real dependency graph, and its tools run right
Measured end to end against three targets on the default plan.

Execution order is now declared, not hand-numbered. `Stage.depends_on` names the
stages one may not start before and the registry derives `level` from it by
longest-path layering, rejecting an unknown name or a cycle. Eleven hand-assigned
barriers collapse to seven real ones: target enrichment no longer gates subdomain
discovery, and the whole post-probe fan-out (origin probe, service fingerprint,
WAF detection, URL discovery, vulnerability scan) is one parallel level instead of
five. The frontend plan solver groups by the same derived level.

Four tool invocations were wrong in ways that cost whole scans:

  - naabu reads `-timeout` as a go duration, so a bare `-timeout 3000` pinned every
    invocation at a fixed ~135s whatever the host and port count. `-timeout 3s`
    returns the identical open-port set: 138s -> 5s on three addresses, and on a
    1,610-address estate 7,428s (two 3,600s timeouts, stage failed) -> 240s.
  - urlfinder has no `-t`; passing one made it exit 2, so the archive URL source had
    never returned a row.
  - amass v4 prints a relationship graph, not a host list, so its output was parsed
    as hostnames and discarded — zero rows in every scan on record while costing the
    full 360s tool budget. Parsed properly now, and out of the default source set.
  - httpx's stream probe was capped at a 900s total, silently truncating a large HTTP
    probe and reporting it as success. It is idle-bounded now, the way dnsx already
    was, and reports PARTIAL when it is stopped.

Celery's redis visibility timeout (3600s default) sat below the 8h task limit, so any
stage running over an hour was delivered twice and the whole rest of the scan ran
again — one run duplicated its entire post-port-scan pipeline, another looped for ten
hours. It now sits above the hard limit.

Work inside stages that was serial and did not need to be: resolver batches
(dns_batch_concurrency, default 3), URL discovery providers, robots/sitemap fetches,
and wafw00f (which has no threads of its own) now run concurrently, with DB writes
kept on the stage thread. wafw00f, screenshots and the WAF stage report what they did
not cover instead of reporting a short run as complete. The cancellation flag is
polled at most every two seconds instead of once per resolved name.

Also: WAF detection and screenshots no longer load whole http_asset rows (stored
response bodies included) to write one column; tool output decodes with
errors="replace" so one non-UTF-8 byte cannot discard a run; the worker runs at
CELERY_SCAN_CONCURRENCY (8) because the widest level now holds five stages.

Measured: vulnweb.com 656s -> 218s with identical results. hamropatro.com completes
in 2,273s where it previously never finished. gov.np's port scan 7,428s -> 240s.

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

147 lines
4.4 KiB
Python

from __future__ import annotations
import contextlib
from collections.abc import Iterator
from shared.logging import get_logger
from tools.runner import CLIToolRunner, OutputFormat, 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
# 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(self, targets: list[str]) -> tuple[list[dict], bool]:
"""Render each target to an image. Returns the records and whether it was cut short."""
if not targets:
return [], False
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}"]
result = self._runner.run(
args=args,
input_data=targets,
input_flag="-l",
use_output_file=False,
output_format=OutputFormat.JSONL,
json_flag="-json",
silent=True,
silent_flag="-silent",
recorder=self.recorder,
tool=HTTPX_BINARY,
extra_args=self.extra_args,
)
return result.json_records, result.timed_out