99 lines
4.4 KiB
Python
99 lines
4.4 KiB
Python
from __future__ import annotations
|
|
|
|
import random
|
|
import string
|
|
import time
|
|
from pathlib import Path
|
|
from urllib.parse import urljoin
|
|
|
|
from redflare.core.models import Finding, ModuleResult, Target
|
|
from .base import Module, ModuleContext
|
|
from .http import request
|
|
|
|
|
|
DEFAULT_PATHS = [
|
|
"admin", "login", "dashboard", "api", "api/health", "graphql",
|
|
"swagger", "openapi.json", ".env", "config.json", "metrics", "actuator/health",
|
|
]
|
|
SENSITIVE = {"admin", "dashboard", ".env", "config.json", "metrics", "graphql", "openapi.json"}
|
|
|
|
|
|
class PathDiscoveryModule(Module):
|
|
name = "path_discovery"
|
|
description = "Rate-limited path discovery with wildcard response filtering"
|
|
|
|
def run(self, target: Target, context: ModuleContext) -> ModuleResult:
|
|
started = time.monotonic()
|
|
result = ModuleResult(self.name, target.url)
|
|
paths = load_paths(context.wordlist)[: context.max_paths]
|
|
interval = 1 / context.rate if context.rate > 0 else 0
|
|
try:
|
|
context.emit(target.url, self.name, "progress", f"Loading {len(paths)} paths; rate={context.rate:g}/s")
|
|
random_path = "redflare-" + "".join(random.choice(string.ascii_lowercase) for _ in range(18))
|
|
baseline = request(urljoin(target.url.rstrip("/") + "/", random_path), context.timeout)
|
|
baseline_signature = (baseline.status, len(baseline.body))
|
|
context.emit(target.url, self.name, "info", f"Wildcard baseline: status={baseline.status} bytes={len(baseline.body)}")
|
|
hits = []
|
|
progress_every = max(1, min(25, len(paths) // 10 or 1))
|
|
for index, path in enumerate(paths, start=1):
|
|
if index == 1 or index % progress_every == 0 or index == len(paths):
|
|
context.emit(target.url, self.name, "progress", f"Probing {index}/{len(paths)}: /{path}")
|
|
if interval:
|
|
time.sleep(interval)
|
|
url = urljoin(target.url.rstrip("/") + "/", path.lstrip("/"))
|
|
try:
|
|
response = request(url, context.timeout, max_body=250_000)
|
|
except Exception as exc:
|
|
result.errors.append(f"{url}: {type(exc).__name__}: {exc}")
|
|
continue
|
|
signature = (response.status, len(response.body))
|
|
if signature == baseline_signature or response.status == 404:
|
|
continue
|
|
hit = {"path": path, "url": url, "status": response.status, "bytes": len(response.body)}
|
|
hits.append(hit)
|
|
context.surface_graph.add_endpoint(
|
|
target.url,
|
|
url,
|
|
method="GET",
|
|
source="path-discovery",
|
|
content_type=response.headers.get("content-type"),
|
|
status=response.status,
|
|
)
|
|
context.emit(target.url, self.name, "info", f"Response HTTP {response.status}: /{path} ({len(response.body)} bytes)")
|
|
if response.status < 400:
|
|
severity = "medium" if path in SENSITIVE else "info"
|
|
result.findings.append(
|
|
Finding(
|
|
context.run_id,
|
|
target.url,
|
|
self.name,
|
|
"discovered-path",
|
|
f"Accessible path discovered: /{path}",
|
|
severity,
|
|
0.8,
|
|
f"The path returned HTTP {response.status} and differed from the wildcard baseline.",
|
|
hit,
|
|
["discovery", "web"],
|
|
)
|
|
)
|
|
result.observations.update({"paths_tested": len(paths), "wildcard_signature": baseline_signature, "hits": hits})
|
|
except Exception as exc:
|
|
result.status = "error"
|
|
result.errors.append(f"{type(exc).__name__}: {exc}")
|
|
result.duration_seconds = round(time.monotonic() - started, 4)
|
|
return result
|
|
|
|
|
|
def load_paths(wordlist: str | None) -> list[str]:
|
|
if not wordlist:
|
|
return list(DEFAULT_PATHS)
|
|
lines = Path(wordlist).read_text(encoding="utf-8").splitlines()
|
|
seen = set()
|
|
paths = []
|
|
for line in lines:
|
|
value = line.strip().lstrip("/")
|
|
if value and not value.startswith("#") and value not in seen:
|
|
seen.add(value)
|
|
paths.append(value)
|
|
return paths
|