mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
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
163 lines
5.2 KiB
Python
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
|