mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-20 07:11:28 +02:00
A full static review of the 3.0 tree, run as two fan-out passes with every
finding verified against the running stack rather than taken on inspection.
Correctness and security
- instance settings encrypted the mask over the stored AI key: `!= MASK` never
matched `••••••••abcd`, so saving settings destroyed the key and `test_ai`
posted the mask as a bearer token
- the MFA token was replayable for its full five-minute life; the gate now
checks revocation and consumes the token after a successful exchange
- logout revoked nothing for a bearer client while answering "Logged out"
- `POST /api-keys/{id}/test` returned the plaintext key inside httpx's error
string, which embeds the whole URL
- an interest rule could be edited or deleted through another project's id
- redis ran the celery broker under `allkeys-lru`, free to evict a queued task
or a chord counter; now `noeviction`
- nuclei's stored command carried the OAST token and every auth header
- a finalize step could poison the session and leave a scan RUNNING for ever
- `is:issue` raised TypeError on every search
- offset paging repeated and skipped rows in four dimensions
- a cancelled run kept a stale `scans.*_found` rollup
- every PDF report raised `KeyError: 'software'`
- `version_key` truncated a wide numeric run and inverted release ordering
- psycopg raises a NUL as a bare ValueError, above StatementError, so every
savepoint guard was blind to the case it was written for
Performance
- endpoints.host had no trigram index: 35.9ms seq scan -> 0.389ms
- free text compiled to one OR, not the prescribed UNION ALL: 132ms -> 41ms
- `/http-assets/summary` and `/ips/summary` materialised whole tables
- the cross-link `ip` kind ran an estate query per page and drew nothing
Frontend
- `$effect` loaders that read the state they write, an unbounded request loop
on a failing endpoint
- error states that rendered as empty states
- 2FA backup codes were unusable while the copy said they worked
- a "Scan history" dialog that always reported none
- ~400 duplicated lines across five filter bars, now one component
Build and tooling
- every scanner installed at @latest; all 13 pinned to the measured versions
- CI built `web/`, gone since 2.x, with tags that cannot be valid
- the prettier pre-commit hook discarded its filenames and always passed
- the test suite destroyed itself when run twice
Also: subdomains from public code, via a pinned github-subdomains source and a
GitHub API key that subfinder receives too; sublist3r and oneforall removed
after measuring one unique host in 89 across four targets.
Verified: 853 tests, ruff clean over eleven packages, svelte-check 0 errors,
the count-is-a-promise gate at 131 checks and 0 mismatches, and 143 GET routes
with no 5xx. Four migrations, each with an exercised downgrade.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DVkdx5ynZ6aof5kLFczmhX
233 lines
8.3 KiB
Python
233 lines
8.3 KiB
Python
import shlex
|
|
|
|
import yaml
|
|
from fastapi import HTTPException, status
|
|
from pydantic import ValidationError
|
|
|
|
from shared.definitions.tools import MAX_TOOL_OPTION_LEN, TOOL_NAMES
|
|
from shared.enums.scan import INTENSITIES
|
|
from shared.services.scan_resolve import (
|
|
_SENSITIVE_HEADER,
|
|
MASK,
|
|
_reject_ctrl,
|
|
redact_command,
|
|
secret_runs,
|
|
)
|
|
from shared.utils.yaml_safe import DocumentTooLargeError, load_document
|
|
from stages.registry import stage_by_name, stages
|
|
|
|
_MAX_HEADERS = 1000
|
|
_MAX_HEADER_LEN = 4096
|
|
_MAX_YAML_LEN = 512 * 1024
|
|
_INTENSITIES = set(INTENSITIES)
|
|
|
|
|
|
def _mask_tool_options(options: dict | None) -> dict[str, str]:
|
|
return {t: redact_command(v) for t, v in (options or {}).items()}
|
|
|
|
|
|
def _unmask_tool_options(submitted: dict | None, stored: dict | None) -> dict[str, str]:
|
|
"""Restore each masked run in place."""
|
|
stored = stored or {}
|
|
out: dict[str, str] = {}
|
|
for tool, value in (submitted or {}).items():
|
|
if not value or MASK not in value or tool not in stored:
|
|
out[tool] = value
|
|
continue
|
|
originals = secret_runs(stored[tool])
|
|
restored = value
|
|
for original in originals:
|
|
restored = restored.replace(MASK, original, 1)
|
|
out[tool] = restored
|
|
return out
|
|
|
|
|
|
def _validate_yaml_source(source: str | None) -> str | None:
|
|
if source is None:
|
|
return None
|
|
if len(source) > _MAX_YAML_LEN:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Stage YAML may not exceed {_MAX_YAML_LEN} bytes.",
|
|
)
|
|
try:
|
|
load_document(source)
|
|
except DocumentTooLargeError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
|
) from exc
|
|
except yaml.YAMLError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid YAML: {exc}"
|
|
) from exc
|
|
return source
|
|
|
|
|
|
def _validate_tool_options(options: dict | None) -> dict[str, str]:
|
|
"""Keep only known tools."""
|
|
clean: dict[str, str] = {}
|
|
for tool, raw in (options or {}).items():
|
|
if tool not in TOOL_NAMES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"'{tool}' does not take custom arguments. "
|
|
f"Tools that do: {', '.join(sorted(TOOL_NAMES))}."
|
|
),
|
|
)
|
|
value = (raw or "").strip()
|
|
if not value:
|
|
continue
|
|
if len(value) > MAX_TOOL_OPTION_LEN:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"{tool} options may not exceed {MAX_TOOL_OPTION_LEN} characters.",
|
|
)
|
|
if MASK in value:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"{tool} options carry a masked value. Enter the value again.",
|
|
)
|
|
_reject_ctrl(f"{tool} options", value)
|
|
try:
|
|
shlex.split(value)
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"{tool} options are not valid shell arguments: {exc}",
|
|
) from exc
|
|
clean[tool] = value
|
|
return clean
|
|
|
|
|
|
def _validate_intensity(intensity: str | None) -> None:
|
|
if intensity is not None and intensity not in _INTENSITIES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"intensity must be one of {sorted(_INTENSITIES)}.",
|
|
)
|
|
|
|
|
|
def _validate_global_headers(headers: list) -> None:
|
|
if headers and len(headers) > _MAX_HEADERS:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"global_headers may not exceed {_MAX_HEADERS} entries.",
|
|
)
|
|
for line in headers or []:
|
|
if isinstance(line, str) and len(line) > _MAX_HEADER_LEN:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Each global header may not exceed {_MAX_HEADER_LEN} characters.",
|
|
)
|
|
if not isinstance(line, str) or ":" not in line:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Each global header must be a 'Name: Value' string.",
|
|
)
|
|
if MASK in line:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"The header '{line.split(':', 1)[0].strip()}' carries a masked "
|
|
"value. Enter the value again."
|
|
),
|
|
)
|
|
name, value = line.split(":", 1)
|
|
if not name.strip():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Each global header must have a non-empty name.",
|
|
)
|
|
_reject_ctrl("Header name", name)
|
|
_reject_ctrl("Header value", value)
|
|
|
|
|
|
def _mask_global_headers(headers: list) -> list[str]:
|
|
out: list[str] = []
|
|
for line in headers or []:
|
|
if isinstance(line, str) and ":" in line:
|
|
name, value = line.split(":", 1)
|
|
if value.strip() and _SENSITIVE_HEADER.search(name.strip()):
|
|
out.append(f"{name.strip()}: {MASK}")
|
|
continue
|
|
out.append(line)
|
|
return out
|
|
|
|
|
|
def _unmask_global_headers(incoming: list, stored: list) -> list[str]:
|
|
stored_map: dict[str, str] = {}
|
|
for line in stored or []:
|
|
if isinstance(line, str) and ":" in line:
|
|
name, value = line.split(":", 1)
|
|
stored_map[name.strip().lower()] = value.strip()
|
|
out: list[str] = []
|
|
for line in incoming or []:
|
|
if isinstance(line, str) and ":" in line:
|
|
name, value = line.split(":", 1)
|
|
if value.strip() == MASK and name.strip().lower() in stored_map:
|
|
out.append(f"{name.strip()}: {stored_map[name.strip().lower()]}")
|
|
continue
|
|
out.append(line)
|
|
return out
|
|
|
|
|
|
def _validate_stages(submitted: dict | None) -> dict[str, dict]:
|
|
known = stage_by_name()
|
|
clean: dict[str, dict] = {}
|
|
for name, authored in (submitted or {}).items():
|
|
spec = known.get(name)
|
|
raw = authored
|
|
if spec is not None and spec.catalog_hidden:
|
|
continue
|
|
if spec is not None and spec.always_on and isinstance(raw, dict):
|
|
raw = {k: v for k, v in raw.items() if k != "enabled"}
|
|
if not raw:
|
|
continue
|
|
if spec is None:
|
|
offered = sorted(n for n, s in known.items() if not s.catalog_hidden)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown stage '{name}'. Known stages: {', '.join(offered)}.",
|
|
)
|
|
if not isinstance(raw, dict):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Stage '{name}' config must be an object.",
|
|
)
|
|
known_fields = set(spec.config_model.model_fields)
|
|
unknown = [k for k in raw if k not in known_fields]
|
|
if unknown:
|
|
hint = ", ".join(sorted(known_fields))
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=(
|
|
f"Stage '{name}' has no setting {', '.join(repr(u) for u in unknown)}. "
|
|
f"Valid settings: {hint}."
|
|
),
|
|
)
|
|
try:
|
|
validated = spec.config_model(**raw).model_dump()
|
|
clean[name] = {k: validated[k] for k in raw if k in validated}
|
|
except ValidationError as exc:
|
|
problems = "; ".join(
|
|
f"{'.'.join(str(p) for p in e['loc'])}: {e['msg']}"
|
|
for e in exc.errors()
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid config for stage '{name}': {problems}",
|
|
) from exc
|
|
return clean
|
|
|
|
|
|
def _full_stages(stored: dict | None) -> dict[str, dict]:
|
|
stored = stored or {}
|
|
return {
|
|
spec.name: spec.config_model(**(stored.get(spec.name) or {})).model_dump(
|
|
mode="json"
|
|
)
|
|
for spec in stages()
|
|
if not spec.catalog_hidden
|
|
}
|