Files
rengine/api/app/services/scan_engine/catalog.py
T
Yogesh OjhaandClaude Opus 5 56fe4d4b00 feat(arsenal): wordlists are a library you upload to, not a path you type
The wordlist field was a free-text absolute path, so the only lists anyone could
actually use were the two baked into the image. Changing that meant a bind mount or a
docker cp — a sysadmin action, not a product one — which in practice meant every
install guessed from the same list. Bruteforce quality is almost entirely wordlist
quality, so the knob was there and nobody could reach it.

Wordlists are now a library, on the same shape as the nuclei check library. Shipped
lists stay in the image and are indexed on read so the picker never starts empty;
uploads land under a `wordlists` volume shared by api and worker. A config field holds
a slug, never a path — `stages/config.py:wordlist(kind)` declares one the way
`threads()` and `rate()` declare theirs, and carries the kind the picker filters on so
a DNS stage is never offered a list of URL paths. `lookup()` still accepts the
absolute paths older engines stored, so an existing engine keeps working.

The UI is the Arsenal's second tab, which is why the Arsenal is now "what your scans
run with" rather than only scanners and their checks: upload, filter by what the words
are, preview the first 200 in the order a scan will read them, delete an upload. Every
list shows the name an engine refers to it by, because that is the thing you type into
a YAML document.

`resolve_path()` refuses anything resolving outside its origin's root and
`delete_custom()` refuses a shipped list — the guard the template library learned the
hard way. A stage that cannot find its list says so and reports PARTIAL rather than
quietly guessing from a different one; that is how the shared volume being absent was
caught during the end-to-end run.

Verified end to end: a seven-word list uploaded through the API, selected by slug,
every passive source disabled — the scan returned six hosts, all attributed to
bruteforce.

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

109 lines
3.7 KiB
Python

from shared.definitions.launch import STAGE_GROUP_LABELS, seed_produces
from shared.definitions.tools import SCAN_TOOLS
from shared.enums.scan import StageGroup
from shared.enums.target import TargetType
from shared.models.scan_engine import (
EngineCatalog,
EnginePreset,
StageCatalogEntry,
StageField,
StageGroupEntry,
ToolOption,
)
from stages.presets import PRESETS, preset_stages
from stages.registry import phases, rate_tools, stages
def _resolve(prop: dict, defs: dict) -> dict:
"""Flatten a $ref/allOf property (an enum field) onto its definition."""
ref = prop.get("$ref") or next(
(m.get("$ref") for m in prop.get("allOf") or [] if isinstance(m, dict)), None
)
if not ref or not ref.startswith("#/$defs/"):
return prop
target = defs.get(ref.rsplit("/", 1)[-1])
return {**target, **prop} if isinstance(target, dict) else prop
def _field_specs(spec) -> list[StageField]:
schema = spec.schema
defs = schema.get("$defs") or {}
defaults = spec.defaults
launch = set(spec.launch_fields)
out: list[StageField] = []
for name, raw in (schema.get("properties") or {}).items():
prop = _resolve(raw, defs)
options = prop.get("enum") or prop.get("options")
out.append(
StageField(
name=name,
title=prop.get("title") or name.replace("_", " ").capitalize(),
description=prop.get("description") or None,
type=_JSON_TYPES.get(prop.get("type"), prop.get("type") or "string"),
default=defaults.get(name, prop.get("default")),
options=list(options) if options else None,
option_labels=prop.get("option_labels") or None,
minimum=prop.get("minimum"),
maximum=prop.get("maximum"),
scale=prop.get("scale"),
widget=prop.get("widget"),
kind=prop.get("kind"),
launch=name in launch,
)
)
return out
def build_catalog() -> EngineCatalog:
return EngineCatalog(
phases=[phase for phase, _ in phases()],
stages=[
StageCatalogEntry(
name=spec.name,
title=spec.title,
description=spec.description,
phase=spec.phase,
level=spec.level,
applies_to=sorted(spec.applies_to),
tools=list(spec.tools),
api_keys=list(spec.api_keys),
requires_api_keys=spec.requires_api_keys,
touches_target=spec.touches_target,
launch_fields=list(spec.launch_fields),
group=spec.group,
role=spec.role,
consumes=sorted(spec.consumes),
produces=sorted(spec.produces),
defaults=spec.defaults,
fields=_field_specs(spec),
)
for spec in stages()
if not spec.catalog_hidden
],
rate_tools=list(rate_tools()),
tool_options=[ToolOption(**t.model_dump()) for t in SCAN_TOOLS],
presets=[
EnginePreset(
name=p.name,
title=p.title,
description=p.description,
stages=preset_stages(p.name),
)
for p in PRESETS
],
target_types=[t.value for t in TargetType],
groups=[
StageGroupEntry(key=g.value, label=STAGE_GROUP_LABELS[g.value])
for g in StageGroup
],
seed_produces={t.value: sorted(seed_produces(t.value)) for t in TargetType},
)
_JSON_TYPES = {
"integer": "integer",
"number": "number",
"boolean": "boolean",
"string": "string",
}