mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 23:31:31 +02:00
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
192 lines
6.7 KiB
Python
192 lines
6.7 KiB
Python
"""On-demand endpoint verification for one branch of the outline."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
|
|
from celery import shared_task
|
|
from sqlalchemy import cast, func, select
|
|
from sqlalchemy.dialects.postgresql import JSONB
|
|
|
|
from app.database import get_sync_session
|
|
from app.orchestrator.stage import load_resolved
|
|
from shared.definitions.endpoints import PROBE_COVERAGE_SOURCE, STATIC_CLASSES
|
|
from shared.definitions.vulnerabilities import CoverageStatus
|
|
from shared.logging import get_logger
|
|
from shared.models.endpoint import Endpoint, EndpointCoverage
|
|
from shared.models.scan import Scan
|
|
from shared.services import endpoint_inventory
|
|
from shared.services.endpoint_inventory import EndpointObservation
|
|
from shared.services.scope_filter import matches_any
|
|
from shared.utils.datetime import utc_now
|
|
from stages.endpoint_probe.config import EndpointProbeConfig
|
|
from tools.httpx.client import HttpxClient, HttpxError
|
|
from tools.httpx.parser import parse_httpx_record
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
MAX_BRANCH_URLS = 2000
|
|
|
|
|
|
def branch_candidates(scan_id: uuid.UUID, host: str, dir_path: str | None):
|
|
"""Unverified, non-static endpoints under a folder, most useful first."""
|
|
flagged = func.jsonb_array_length(cast(Endpoint.interest, JSONB)) > 0
|
|
query = select(Endpoint.url, Endpoint.path).where(
|
|
Endpoint.scan_id == scan_id,
|
|
Endpoint.host == host,
|
|
Endpoint.is_probed.is_(False),
|
|
Endpoint.endpoint_class.notin_(tuple(STATIC_CLASSES)),
|
|
)
|
|
if dir_path and dir_path != "/":
|
|
prefix = dir_path if dir_path.endswith("/") else f"{dir_path}/"
|
|
escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
query = query.where(Endpoint.dir_path.like(f"{escaped}%", escape="\\"))
|
|
return query.order_by(
|
|
flagged.desc(),
|
|
(Endpoint.param_count > 0).desc(),
|
|
Endpoint.depth.asc(),
|
|
Endpoint.url.asc(),
|
|
)
|
|
|
|
|
|
@shared_task(bind=True, name="app.tasks.endpoints.verify_branch", max_retries=0)
|
|
def verify_branch(
|
|
self, # noqa: ARG001
|
|
scan_id: str,
|
|
host: str,
|
|
dir_path: str | None = None,
|
|
limit: int = 500,
|
|
) -> dict:
|
|
started = utc_now()
|
|
cap = max(1, min(int(limit), MAX_BRANCH_URLS))
|
|
with get_sync_session() as session:
|
|
scan = session.get(Scan, uuid.UUID(scan_id))
|
|
if scan is None:
|
|
return {"error": "scan not found"}
|
|
resolved = load_resolved(scan.execution_config)
|
|
cfg = EndpointProbeConfig()
|
|
rows = session.execute(
|
|
branch_candidates(scan.id, host, dir_path).limit(cap * 4)
|
|
).all()
|
|
excluded = resolved.excluded_paths or []
|
|
if excluded:
|
|
rows = [r for r in rows if not matches_any(r.path, excluded)]
|
|
selected = [r.url for r in rows[:cap]]
|
|
skipped = max(0, len(rows) - len(selected))
|
|
where = f"{dir_path or '/'} on {host}"
|
|
if not selected:
|
|
return {"verified": 0, "answered": 0}
|
|
|
|
try:
|
|
client = HttpxClient(
|
|
rate_limit=cfg.rate,
|
|
threads=cfg.threads,
|
|
timeout=cfg.timeout,
|
|
proxy_url=resolved.proxy_url,
|
|
headers=dict(resolved.headers or {}),
|
|
follow_redirects=cfg.follow_redirects,
|
|
extra_args=resolved.tool_args("httpx"),
|
|
)
|
|
except HttpxError as e:
|
|
_store(
|
|
session,
|
|
scan,
|
|
started,
|
|
len(selected),
|
|
0,
|
|
None,
|
|
CoverageStatus.SKIPPED.value,
|
|
str(e)[:2000],
|
|
f"On-demand verification of {where}",
|
|
)
|
|
logger.warning("httpx unavailable, branch stays unverified")
|
|
return {"verified": 0, "answered": 0, "error": str(e)[:200]}
|
|
|
|
observations: list[EndpointObservation] = []
|
|
with client.stream_probe(selected) as stream:
|
|
for record in stream.records:
|
|
fields = parse_httpx_record(record)
|
|
url = fields.get("url")
|
|
if not url:
|
|
continue
|
|
observations.append(
|
|
EndpointObservation(
|
|
url=url,
|
|
is_probed=True,
|
|
status_code=fields.get("status_code"),
|
|
content_type=fields.get("content_type"),
|
|
content_length=fields.get("content_length"),
|
|
title=fields.get("title"),
|
|
words=fields.get("words"),
|
|
lines=fields.get("lines"),
|
|
response_time=fields.get("response_time"),
|
|
redirect_location=fields.get("location"),
|
|
content_hash=fields.get("content_hash"),
|
|
tech=list(fields.get("tech") or []),
|
|
methods=[fields["method"]] if fields.get("method") else [],
|
|
)
|
|
)
|
|
written = endpoint_inventory.verify(
|
|
session, scan_id=scan.id, observations=observations
|
|
)
|
|
status = (
|
|
CoverageStatus.PARTIAL.value if skipped else CoverageStatus.COMPLETED.value
|
|
)
|
|
reason = f"On-demand verification of {where}" + (
|
|
f"; {skipped} more were left unverified by the limit of {cap}."
|
|
if skipped
|
|
else ""
|
|
)
|
|
_store(
|
|
session,
|
|
scan,
|
|
started,
|
|
len(selected) + skipped,
|
|
len(selected),
|
|
written.updated,
|
|
status,
|
|
None,
|
|
reason,
|
|
)
|
|
logger.info(
|
|
"verified branch", host=host, dir_path=dir_path, requested=len(selected)
|
|
)
|
|
return {"verified": len(selected), "answered": written.updated}
|
|
|
|
|
|
def _store(
|
|
session,
|
|
scan: Scan,
|
|
started,
|
|
total: int,
|
|
probed: int,
|
|
answered: int | None,
|
|
status: str,
|
|
error: str | None,
|
|
reason: str,
|
|
) -> None:
|
|
ended = utc_now()
|
|
session.add(
|
|
EndpointCoverage(
|
|
scan_id=scan.id,
|
|
target_id=scan.target_id,
|
|
project_id=scan.project_id,
|
|
source=PROBE_COVERAGE_SOURCE,
|
|
tool="httpx",
|
|
status=status,
|
|
hosts_total=1,
|
|
hosts_scanned=1,
|
|
urls_found=total,
|
|
urls_probed=probed,
|
|
urls_stored=answered,
|
|
errors=None if answered is None else max(0, probed - answered),
|
|
capped=total > probed,
|
|
cap_reason=reason,
|
|
error=error,
|
|
started_at=started,
|
|
ended_at=ended,
|
|
duration_seconds=round((ended - started).total_seconds(), 2),
|
|
)
|
|
)
|
|
session.commit()
|