refactor(engine): one intensity table, tiered fields, built-in Default engine

Every stage config field is a decision; rate, concurrency, timeout and
retries come from a per-tool intensity profile the stage reads as its
transport. 173 fields become 83: 41 transport values, 40 constants and
the dead ones leave the models, and the builder groups by capability
with support stages as checkboxes and advanced fields behind one
disclosure. Aggressive now changes what a tool is handed.

Every project carries a built-in Default engine, seeded on project
creation and on first list, undeletable: stage defaults, subfinder as
the only passive source, vulnerability scan on with nuclei.

From the tracing review: tool args can no longer restate a flag the
stage set, the session check obeys the probe scheme, excluded scope
reaches every stage that reads addresses, hosts or paths, the sitemap
and source-map providers throttle at the transport rate, the blind tier
obeys its switch, the fuzzing lanes split the budget, and the removed
private-address and evidence switches are back as advanced fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq4xYJbgW5euUa9sCWBKFw
This commit is contained in:
Yogesh Ojha
2026-09-14 12:26:27 +05:30
co-authored by Claude Fable 5.1
parent dd7ff0f6f1
commit 695c4803bb
82 changed files with 1831 additions and 1039 deletions
@@ -0,0 +1,37 @@
"""One built-in engine per project.
Revision ID: c9e4f2a71b58
Revises: b7d3e1f04a26
Create Date: 2026-09-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision: str = "c9e4f2a71b58"
down_revision: str | None = "b7d3e1f04a26"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"scan_engines",
sa.Column(
"builtin", sa.Boolean(), nullable=False, server_default=sa.false()
),
)
op.create_index(
"ux_scan_engines_builtin_project",
"scan_engines",
["project_id"],
unique=True,
postgresql_where=sa.text("builtin"),
)
def downgrade() -> None:
op.drop_index("ux_scan_engines_builtin_project", table_name="scan_engines")
op.drop_column("scan_engines", "builtin")
+160
View File
@@ -0,0 +1,160 @@
"""Drop the transport and constant fields from stored engines.
Revision ID: b7d3e1f04a26
Revises: a4c2e7d91f53
Create Date: 2026-09-14
"""
from __future__ import annotations
import json
import sqlalchemy as sa
from alembic import op
revision: str = "b7d3e1f04a26"
down_revision: str | None = "a4c2e7d91f53"
branch_labels = None
depends_on = None
_REMOVED: dict[str, set[str]] = {
"content_discovery": {"threads", "rate", "timeout"},
"dast_scan": {
"max_dirs_per_origin",
"dir_depth",
"fuzz_param_frequency",
"rate",
"threads",
"bulk_size",
"timeout",
"retries",
"max_host_error",
"waf_rate_divisor",
"honeypot_threshold",
"interactsh_server",
"oast_wait_minutes",
"store_evidence",
"max_evidence_recovery",
"max_findings",
},
"endpoint_probe": {"threads", "timeout", "rate", "follow_redirects"},
"host_discovery": {"rate", "threads", "timeout"},
"http_probe": {
"threads",
"timeout",
"rate",
"follow_redirects",
"probe_open_ports",
"max_ports_per_host",
},
"netblock_sweep": {
"min_addresses",
"max_asn_addresses",
"min_share",
"dns_threads",
"dns_timeout",
},
"origin_probe": {"threads", "timeout", "rate", "max_ports_per_address"},
"passive_ports": {"max_addresses"},
"port_scan": {"rate", "threads", "timeout", "retries", "port_threshold"},
"reverse_dns": {"dns_threads", "dns_timeout"},
"screenshot": {"threads", "rate", "timeout"},
"seed_resolution": {"cidr_skip_rfc1918"},
"service_fingerprint": {"threads", "timeout", "include_unknown"},
"session_check": {"timeout"},
"subdomain_discovery": {
"permutation_seeds",
"permutation_limit",
"dns_threads",
"dns_batch_size",
"dns_batch_concurrency",
"dns_idle_timeout",
"dns_retry_silent",
},
"target_enrichment": {"dns_threads", "dns_timeout"},
"url_discovery": {
"threads",
"timeout",
"rate",
"crawl_javascript",
"max_urls",
"max_known_file_hosts",
"max_source_maps",
"max_archive_domains",
},
"vhost": {"threads", "rate"},
"vulnerability_scan": {
"rate",
"threads",
"bulk_size",
"timeout",
"retries",
"max_host_error",
"waf_rate_divisor",
"honeypot_threshold",
"oast_wait_minutes",
"max_evidence_recovery",
"max_findings",
},
}
def _prune(stages: dict) -> tuple[dict, bool]:
changed = False
out: dict = {}
for name, config in (stages or {}).items():
if not isinstance(config, dict):
out[name] = config
continue
gone = _REMOVED.get(name, set())
kept = {k: v for k, v in config.items() if k not in gone}
if len(kept) != len(config):
changed = True
if kept:
out[name] = kept
elif config:
changed = True
return out, changed
def _yaml_mentions_removed(source: str | None) -> bool:
if not source:
return False
try:
import yaml # noqa: PLC0415
doc = yaml.safe_load(source) or {}
except Exception: # noqa: BLE001
return False
stages = doc.get("stages") if isinstance(doc, dict) else None
if not isinstance(stages, dict):
return False
return any(
isinstance(cfg, dict) and set(cfg) & _REMOVED.get(name, set())
for name, cfg in stages.items()
)
def upgrade() -> None:
bind = op.get_bind()
rows = bind.execute(
sa.text("SELECT id, stages, yaml_source FROM scan_engines")
).all()
for id_, stages, yaml_source in rows:
raw = stages if isinstance(stages, dict) else json.loads(stages or "{}")
pruned, changed = _prune(raw)
drop_yaml = _yaml_mentions_removed(yaml_source)
if not changed and not drop_yaml:
continue
bind.execute(
sa.text(
"UPDATE scan_engines SET stages = :stages, "
"yaml_source = CASE WHEN :drop THEN NULL ELSE yaml_source END "
"WHERE id = :id"
),
{"stages": json.dumps(pruned), "drop": drop_yaml, "id": id_},
)
def downgrade() -> None:
pass
+2
View File
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import CurrentSuperuser, CurrentUser
from app.core.database import get_session
from app.services.instance_settings import InstanceSettingsService
from app.services.scan_engine import ScanEngineService
from shared.models.api_key import APIKey
from shared.models.notification_channel import NotificationChannel
from shared.models.project import Project, ProjectCreate, ProjectRead
@@ -128,6 +129,7 @@ async def create_first_project(
created_by=current_user.id,
)
await add_with_unique_slug(session, project, data.name)
await ScanEngineService(session).ensure_builtin(project.id, current_user.id)
await session.commit()
await session.refresh(project)
return project
+2
View File
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import CurrentSuperuser, CurrentUser
from app.core.database import get_session
from app.services.scan_engine import ScanEngineService
from shared.models.organization import Organization
from shared.models.project import Project, ProjectCreate, ProjectRead, ProjectSummary
from shared.models.tag import Tag
@@ -77,6 +78,7 @@ async def create_project(
created_by=current_user.id,
)
await add_with_unique_slug(session, project, project_in.name)
await ScanEngineService(session).ensure_builtin(project.id, current_user.id)
await session.commit()
await session.refresh(project)
return project
+2 -2
View File
@@ -49,11 +49,11 @@ async def preview_engine_stages(
@router.get("", response_model=list[ScanEngineRead])
async def list_engines(
_current_user: CurrentUser,
current_user: CurrentUser,
service: Annotated[ScanEngineService, Depends(get_service)],
project_id: Annotated[UUID, Query(description="Project ID")],
):
return await service.list(project_id)
return await service.list(project_id, created_by=current_user.id)
@router.post("", response_model=ScanEngineRead, status_code=status.HTTP_201_CREATED)
+18 -2
View File
@@ -1,6 +1,6 @@
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.scan import INTENSITIES, StageGroup
from shared.enums.target import TargetType
from shared.models.scan_engine import (
EngineCatalog,
@@ -8,8 +8,10 @@ from shared.models.scan_engine import (
StageCatalogEntry,
StageField,
StageGroupEntry,
StageTransport,
ToolOption,
)
from stages.config import FieldTier
from stages.presets import PRESETS, preset_stages
from stages.registry import phases, rate_tools, stages
@@ -30,6 +32,7 @@ def _field_specs(spec) -> list[StageField]:
defs = schema.get("$defs") or {}
defaults = spec.defaults
launch = set(spec.launch_fields)
tiers = spec.config_model.tiers()
out: list[StageField] = []
for name, raw in (schema.get("properties") or {}).items():
prop = _resolve(raw, defs)
@@ -45,7 +48,7 @@ def _field_specs(spec) -> list[StageField]:
option_labels=prop.get("option_labels") or None,
minimum=prop.get("minimum"),
maximum=prop.get("maximum"),
scale=prop.get("scale"),
tier=tiers.get(name, FieldTier.BASIC.value),
widget=prop.get("widget"),
kind=prop.get("kind"),
launch=name in launch,
@@ -54,6 +57,18 @@ def _field_specs(spec) -> list[StageField]:
return out
def _transport(spec) -> StageTransport | None:
if spec.transport_tool is None:
return None
by_intensity = {level: spec.transport(level) for level in INTENSITIES}
return StageTransport(
tool=spec.transport_tool,
rates={level: t.rate for level, t in by_intensity.items()},
threads={level: t.threads for level, t in by_intensity.items()},
timeout=by_intensity[INTENSITIES[0]].timeout,
)
def build_catalog() -> EngineCatalog:
return EngineCatalog(
phases=[phase for phase, _ in phases()],
@@ -75,6 +90,7 @@ def build_catalog() -> EngineCatalog:
role=spec.role,
consumes=sorted(spec.consumes),
produces=sorted(spec.produces),
transport=_transport(spec),
defaults=spec.defaults,
fields=_field_specs(spec),
)
+4 -9
View File
@@ -13,7 +13,6 @@ from shared.models.scan_context import ScanContext
from shared.models.scan_engine import EnginePreviewResult, PreviewResolved
from shared.models.scan_preview import PreviewPhase, PreviewTool, PreviewToolStatus
from shared.services.scan_resolve import merge_engine_context
from stages.config import Scale
from stages.registry import ordered_levels, phases
@@ -88,18 +87,14 @@ def _stage_status(
f"{spec.title}: no API key for {', '.join(missing)}. Reduced coverage."
)
scaled = spec.config_model.scaled_fields()
def _scaled(kind: Scale):
return next((values[f] for f, (s, _) in scaled.items() if s is kind), None)
transport = (resolved.transports or {}).get(spec.name) or {}
return PreviewTool(
capability=spec.name,
label=spec.title,
status=PreviewToolStatus.WILL_RUN,
rate=_scaled(Scale.RATE),
threads=_scaled(Scale.THREADS),
timeout=_scaled(Scale.TIMEOUT),
rate=transport.get("rate"),
threads=transport.get("threads"),
timeout=transport.get("timeout"),
)
+52 -1
View File
@@ -3,6 +3,7 @@ from uuid import UUID
import yaml
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.scan_engine.validation import (
@@ -18,6 +19,12 @@ from app.services.scan_engine.validation import (
_validate_tool_options,
_validate_yaml_source,
)
from shared.definitions.default_engine import (
DEFAULT_ENGINE_DESCRIPTION,
DEFAULT_ENGINE_INTENSITY,
DEFAULT_ENGINE_NAME,
default_engine_stages,
)
from shared.enums.scan import SCAN_OPEN_STATUSES
from shared.models.scan import Scan
from shared.models.scan_engine import (
@@ -59,6 +66,7 @@ def _to_read(engine: ScanEngine, usage: EngineUsage | None = None) -> ScanEngine
stages=dict(engine.stages or {}),
yaml_source=engine.yaml_source,
tool_options=_mask_tool_options(engine.tool_options),
builtin=bool(engine.builtin),
created_at=engine.created_at,
updated_at=engine.updated_at,
last_used_at=engine.last_used_at,
@@ -121,7 +129,45 @@ class ScanEngineService:
await self.session.refresh(engine)
return _to_read(engine)
async def list(self, project_id: UUID) -> list[ScanEngineRead]:
async def ensure_builtin(self, project_id: UUID, created_by: UUID) -> ScanEngine:
"""The project's built-in engine, created on first sight."""
existing = await self._builtin_for(project_id)
if existing is not None:
return existing
engine = ScanEngine(
project_id=project_id,
created_by=created_by,
name=DEFAULT_ENGINE_NAME,
description=DEFAULT_ENGINE_DESCRIPTION,
intensity=DEFAULT_ENGINE_INTENSITY,
stages=_validate_stages(default_engine_stages()),
builtin=True,
)
try:
async with self.session.begin_nested():
self.session.add(engine)
await self.session.flush()
except IntegrityError:
raced = await self._builtin_for(project_id)
if raced is None:
raise
return raced
return engine
async def _builtin_for(self, project_id: UUID) -> ScanEngine | None:
result = await self.session.execute(
select(ScanEngine).where(
ScanEngine.project_id == project_id, ScanEngine.builtin.is_(True)
)
)
return result.scalar_one_or_none()
async def list(
self, project_id: UUID, created_by: UUID | None = None
) -> list[ScanEngineRead]:
if created_by is not None:
await self.ensure_builtin(project_id, created_by)
await self.session.commit()
result = await self.session.execute(
select(ScanEngine)
.where(ScanEngine.project_id == project_id)
@@ -173,6 +219,11 @@ class ScanEngineService:
async def delete(self, id: UUID, project_id: UUID) -> bool:
engine = await self._get_or_404(id, project_id)
if engine.builtin:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"{engine.name} is built in and is not deleted.",
)
running = await _running_scans_for(self.session, engine.id)
if running:
raise HTTPException(
@@ -73,7 +73,7 @@
data-selected={isSelected}
>
<div class="flex items-start gap-3 px-4 pt-4">
{#if onSelect}
{#if onSelect && !engine.builtin}
<div
class="relative z-10 flex h-5 items-center opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 data-[on=true]:opacity-100 [@media(hover:none)]:opacity-100"
data-on={isSelected}
@@ -98,6 +98,9 @@
<Badge variant={INTENSITY_VARIANT[engine.intensity] ?? 'secondary'} class="text-2xs">
{INTENSITY_LABELS[engine.intensity] ?? engine.intensity}
</Badge>
{#if engine.builtin}
<Badge variant="outline" class="text-2xs">Built in</Badge>
{/if}
</div>
{#if engine.description}
<p class="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{engine.description}</p>
@@ -128,11 +131,13 @@
<Download size={13} />
Export YAML
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={() => onDelete?.()}>
<Trash2 size={13} />
Delete
</DropdownMenu.Item>
{#if !engine.builtin}
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onclick={() => onDelete?.()}>
<Trash2 size={13} />
Delete
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -161,9 +161,16 @@
Duplicate
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" disabled={!onDelete} onclick={() => onDelete?.()}>
<DropdownMenu.Item
variant="destructive"
disabled={!onDelete || engine.builtin}
onclick={() => onDelete?.()}
>
<Trash2 size={13} />
Delete
{#if engine.builtin}
<span class="ml-auto text-2xs text-muted-foreground">Built in</span>
{/if}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -8,7 +8,6 @@
import { Button } from '$lib/components/ui/button';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import type { StageField } from '$lib/types/scan-engine';
import { SCALE_HELP } from '$lib/types/scan-engine';
import { parseCsv } from '$lib/utilities/parse';
import CustomTemplatesField from './custom-templates-field.svelte';
import WordlistField from './wordlist-field.svelte';
@@ -72,11 +71,6 @@
{#if field.description}
<Field.Description class="text-2xs leading-snug">{field.description}</Field.Description>
{/if}
{#if field.scale}
<Field.Description class="text-2xs leading-snug opacity-75">
{SCALE_HELP[field.scale]}
</Field.Description>
{/if}
</Field.Content>
<div class="shrink-0">
@@ -1,6 +1,7 @@
<script lang="ts">
import * as Collapsible from '$lib/components/ui/collapsible';
import { Switch } from '$lib/components/ui/switch';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -10,21 +11,18 @@
import KeyRound from '@lucide/svelte/icons/key-round';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import StageFieldRow from './stage-field.svelte';
import type { StageCatalogEntry, StageConfig } from '$lib/types/scan-engine';
import { targetTypeLabel } from '$lib/types/scan-engine';
type RailKind = 'none' | 'solid' | 'dotted';
import type { StageCatalogEntry, StageConfig, StageField } from '$lib/types/scan-engine';
import { advancedFields, basicFields, targetTypeLabel } from '$lib/types/scan-engine';
interface Props {
stage: StageCatalogEntry;
config: StageConfig;
open: boolean;
active: boolean;
railUp?: RailKind;
railDown?: RailKind;
applicable: boolean;
blockedByIntensity: boolean;
lensTargetType: string | null;
support?: boolean;
onToggleOpen: () => void;
onChange: (field: string, value: unknown) => void;
onReset: () => void;
@@ -35,22 +33,24 @@
config,
open,
active,
railUp = 'none',
railDown = 'none',
applicable,
blockedByIntensity,
lensTargetType,
support = false,
onToggleOpen,
onChange,
onReset
}: Props = $props();
let advancedOpen = $state(false);
const alwaysOn = $derived(Boolean(stage.always_on));
const enabled = $derived(alwaysOn || Boolean(config.enabled ?? stage.defaults.enabled));
const dimmed = $derived(!applicable || blockedByIntensity);
const nodeState = $derived(!enabled ? 'off' : dimmed ? 'dim' : 'on');
const settingFields = $derived(stage.fields.filter((f) => f.name !== 'enabled'));
const basic = $derived(basicFields(stage));
const advanced = $derived(advancedFields(stage));
const changed = $derived(
stage.fields.filter((f) => {
@@ -59,25 +59,23 @@
return JSON.stringify(current) !== JSON.stringify(stage.defaults[f.name]);
})
);
const advancedChanged = $derived(changed.filter((f) => f.tier === 'advanced').length);
function describe(field: StageField, value: unknown): string | null {
if (value === undefined || value === null || value === '') return null;
if (Array.isArray(value)) {
return value.length ? `${value.length} ${field.title.toLowerCase()}` : null;
}
if (typeof value === 'boolean') return value ? field.title.toLowerCase() : null;
if (typeof value === 'number') return `${field.title.toLowerCase()} ${value.toLocaleString()}`;
return field.option_labels?.[String(value)] ?? String(value);
}
const summary = $derived.by(() => {
const parts: string[] = [];
for (const field of settingFields.slice(0, 6)) {
const value = config[field.name] ?? stage.defaults[field.name];
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length) parts.push(`${value.length} ${field.title.toLowerCase()}`);
} else if (typeof value === 'boolean') {
if (value) parts.push(field.title.toLowerCase());
} else if (field.scale === 'rate') {
parts.push(`${value}/s`);
} else if (field.scale === 'threads') {
parts.push(`${value} threads`);
} else if (field.scale === 'timeout') {
parts.push(`${value}s`);
} else {
parts.push(String(value));
}
for (const field of basic) {
const text = describe(field, config[field.name] ?? stage.defaults[field.name]);
if (text) parts.push(text);
}
return parts.slice(0, 3).join(' · ');
});
@@ -90,11 +88,28 @@
data-active={active}
data-dim={dimmed}
data-enabled={enabled}
data-support={support}
>
<span class="rail up" data-kind={railUp} aria-hidden="true"></span>
<span class="rail down" data-kind={railDown} aria-hidden="true"></span>
<div class="head">
<span class="node" data-state={nodeState} aria-hidden="true"></span>
{#if support}
{#if alwaysOn}
<Hint text="Always on">
{#snippet child(props)}
<span {...props} class="inline-flex">
<Checkbox checked disabled aria-label="{stage.title} is always on" />
</span>
{/snippet}
</Hint>
{:else}
<Checkbox
checked={enabled}
onCheckedChange={(v) => onChange('enabled', Boolean(v))}
aria-label="Enable {stage.title}"
/>
{/if}
{:else}
<span class="node" data-state={nodeState} aria-hidden="true"></span>
{/if}
<Collapsible.Trigger class="disclose" aria-label="{open ? 'Collapse' : 'Expand'} {stage.title}">
<ChevronRight size={14} class="chev" />
@@ -114,9 +129,6 @@
</Collapsible.Trigger>
<div class="meta">
{#if alwaysOn}
<Badge variant="outline" class="tag">Always on</Badge>
{/if}
{#if blockedByIntensity}
<Badge variant="outline" class="tag">Skipped at passive</Badge>
{:else if !applicable && lensTargetType}
@@ -151,20 +163,22 @@
</Tooltip.Root>
{/if}
{#if alwaysOn}
<Hint text="Always on">
{#snippet child(props)}
<span {...props} class="inline-flex">
<Switch checked disabled aria-label="{stage.title} is always on" />
</span>
{/snippet}
</Hint>
{:else}
<Switch
checked={enabled}
onCheckedChange={(v) => onChange('enabled', v)}
aria-label="Enable {stage.title}"
/>
{#if !support}
{#if alwaysOn}
<Hint text="Always on">
{#snippet child(props)}
<span {...props} class="inline-flex">
<Switch checked disabled aria-label="{stage.title} is always on" />
</span>
{/snippet}
</Hint>
{:else}
<Switch
checked={enabled}
onCheckedChange={(v) => onChange('enabled', v)}
aria-label="Enable {stage.title}"
/>
{/if}
{/if}
</div>
</div>
@@ -180,9 +194,9 @@
</div>
{/if}
{#if settingFields.length}
{#if basic.length}
<div class="fields">
{#each settingFields as field (field.name)}
{#each basic as field (field.name)}
<StageFieldRow
{field}
stageName={stage.name}
@@ -191,10 +205,36 @@
/>
{/each}
</div>
{:else}
{:else if !advanced.length}
<p class="desc">No settings.</p>
{/if}
{#if advanced.length}
<Collapsible.Root bind:open={advancedOpen} class="advanced">
<Collapsible.Trigger class="advanced-head">
<ChevronRight size={12} class="chev" />
<span>Advanced</span>
<span class="advanced-count">
{advanced.length} setting{advanced.length === 1 ? '' : 's'}{advancedChanged
? ` · ${advancedChanged} changed`
: ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="fields">
{#each advanced as field (field.name)}
<StageFieldRow
{field}
stageName={stage.name}
value={config[field.name]}
onChange={(v) => onChange(field.name, v)}
/>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
{#if changed.length}
<div class="foot">
<Button variant="ghost" size="sm" class="h-7 gap-1.5 text-xs" onclick={onReset}>
@@ -233,6 +273,12 @@
:global(.row[data-enabled='false']) .title {
color: var(--muted-foreground);
}
:global(.row[data-support='true']) {
background: color-mix(in oklch, var(--muted) 30%, transparent);
}
:global(.row[data-support='true']) .title {
font-weight: 450;
}
.head {
display: flex;
@@ -241,6 +287,9 @@
padding: 0 14px 0 12px;
min-height: 46px;
}
:global(.row[data-support='true']) .head {
min-height: 40px;
}
.node {
position: relative;
@@ -261,34 +310,6 @@
background: color-mix(in oklch, var(--muted-foreground) 35%, transparent);
border-color: transparent;
}
.rail {
position: absolute;
left: 15px;
width: 3px;
pointer-events: none;
}
.rail.up {
top: 0;
height: 23px;
}
.rail.down {
top: 23px;
bottom: 0;
}
.rail[data-kind='none'] {
display: none;
}
.rail[data-kind='solid'] {
background: linear-gradient(var(--border), var(--border)) center / 1px 100% no-repeat;
}
.rail[data-kind='dotted'] {
background: radial-gradient(
circle,
color-mix(in oklch, var(--muted-foreground) 65%, transparent) 1px,
transparent 1.6px
)
center / 3px 5px repeat-y;
}
:global(.row .disclose) {
display: flex;
@@ -308,7 +329,7 @@
color: var(--muted-foreground);
transition: transform 0.15s ease;
}
:global(.row[data-state='open'] .disclose .chev) {
:global(.row[data-state='open'] > .head .disclose .chev) {
transform: rotate(90deg);
}
.title {
@@ -339,7 +360,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 240px;
max-width: 260px;
font-variant-numeric: tabular-nums;
}
.meta :global(.tag) {
@@ -383,6 +404,30 @@
.fields > :global(* + *) {
border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent);
}
:global(.row .advanced) {
margin-top: 6px;
border-top: 1px solid color-mix(in oklch, var(--border) 60%, transparent);
}
:global(.row .advanced-head) {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 10px 0 6px;
background: none;
border: none;
cursor: pointer;
color: var(--muted-foreground);
font-size: 12px;
font-weight: 500;
}
:global(.row .advanced[data-state='open'] .advanced-head .chev) {
transform: rotate(90deg);
}
.advanced-count {
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.foot {
margin-top: 8px;
padding-top: 8px;
@@ -1,12 +1,14 @@
<script lang="ts">
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as Popover from '$lib/components/ui/popover';
import { Button } from '$lib/components/ui/button';
import StageFieldRow from '$lib/components/engines/stage-field.svelte';
import VulnPlan from '$lib/components/scans/vuln-plan.svelte';
import { VULN_STAGE } from '$lib/config/vulnerabilities';
import type { StageCatalogEntry } from '$lib/types/scan-engine';
import { advancedFields, basicFields, type StageCatalogEntry } from '$lib/types/scan-engine';
import type { LaunchState } from './launch-state.svelte';
interface Props {
@@ -18,15 +20,17 @@
const VULN_PLAN_FIELDS = ['severities', 'template_sets'];
let advancedOpen = $state(false);
let isVuln = $derived(stage.name === VULN_STAGE);
let fields = $derived(
stage.fields.filter(
(f) => f.name !== 'enabled' && !(isVuln && VULN_PLAN_FIELDS.includes(f.name))
)
let basic = $derived(
basicFields(stage).filter((f) => !(isVuln && VULN_PLAN_FIELDS.includes(f.name)))
);
let advanced = $derived(advancedFields(stage));
let changedFields = $derived(
Object.keys(launch.patch[stage.name] ?? {}).filter((f) => f !== 'enabled')
);
let advancedChanged = $derived(advanced.filter((f) => changedFields.includes(f.name)).length);
function applyVulnPlan(overrides: Record<string, Record<string, unknown>>) {
const next = overrides[stage.name] ?? {};
for (const field of VULN_PLAN_FIELDS) {
@@ -81,9 +85,9 @@
onChange={applyVulnPlan}
/>
{/if}
{#if fields.length}
{#if basic.length}
<div class="divide-y divide-border {isVuln ? 'mt-3 border-t' : ''}">
{#each fields as field (field.name)}
{#each basic as field (field.name)}
<StageFieldRow
{field}
stageName={stage.name}
@@ -93,5 +97,32 @@
{/each}
</div>
{/if}
{#if advanced.length}
<Collapsible.Root bind:open={advancedOpen} class="mt-2 border-t border-border pt-1">
<Collapsible.Trigger
class="flex w-full items-center gap-1.5 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ChevronRight class="size-3 transition-transform {advancedOpen ? 'rotate-90' : ''}" />
Advanced
<span class="font-normal tabular-nums">
{advanced.length} setting{advanced.length === 1 ? '' : 's'}{advancedChanged
? ` · ${advancedChanged} changed`
: ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="divide-y divide-border">
{#each advanced as field (field.name)}
<StageFieldRow
{field}
stageName={stage.name}
value={launch.effective[stage.name]?.[field.name]}
onChange={(value) => launch.setStageField(stage.name, field.name, value)}
/>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
</Popover.Content>
</Popover.Root>
@@ -37,6 +37,15 @@ function createEngineCatalogStore() {
return catalog?.stages.find((s) => s.name === name);
},
byGroup(): { key: string; label: string; stages: StageCatalogEntry[] }[] {
if (!catalog) return [];
return catalog.groups.map((group) => ({
key: group.key,
label: group.label,
stages: catalog!.stages.filter((s) => s.group === group.key)
}));
},
byPhase(): { phase: string; stages: StageCatalogEntry[] }[] {
if (!catalog) return [];
return catalog.phases.map((phase) => ({
+27 -10
View File
@@ -10,9 +10,10 @@ export const INTENSITY_LABELS: Record<Intensity, string> = {
};
export const INTENSITY_HELP: Record<Intensity, string> = {
passive: 'Passive sources only. No traffic is sent to the target.',
normal: 'Active scanning at standard rate limits.',
aggressive: 'Active scanning at higher request rates. More likely to be detected.'
passive: 'Public sources only. No traffic is sent to the target.',
normal: '150 requests per second per tool, 1,000 packets per second for the port scan.',
aggressive:
'400 requests per second per tool, 3,000 packets per second for the port scan. Concurrency doubled.'
};
export type StageConfig = Record<string, unknown>;
@@ -34,6 +35,7 @@ export interface ScanEngine {
yaml_source: string | null;
tool_options: Record<string, string>;
usage: EngineUsage;
builtin: boolean;
created_at: string;
updated_at: string;
last_used_at: string | null;
@@ -52,7 +54,8 @@ export interface ScanEngineCreate {
export type ScanEngineUpdate = Partial<Omit<ScanEngineCreate, 'name'>> & { name?: string };
export type FieldType = 'boolean' | 'integer' | 'number' | 'string' | 'array';
export type FieldScale = 'threads' | 'timeout' | 'rate';
export type FieldTier = 'basic' | 'advanced';
export const ADVANCED_TIER: FieldTier = 'advanced';
export interface StageField {
name: string;
@@ -64,12 +67,19 @@ export interface StageField {
option_labels: Record<string, string> | null;
minimum: number | null;
maximum: number | null;
scale: FieldScale | null;
tier: FieldTier;
widget: string | null;
kind: string | null;
launch: boolean;
}
export interface StageTransport {
tool: string;
rates: Record<string, number | null>;
threads: Record<string, number>;
timeout: number;
}
export interface StageCatalogEntry {
name: string;
title: string;
@@ -87,6 +97,7 @@ export interface StageCatalogEntry {
role: string;
consumes: string[];
produces: string[];
transport?: StageTransport | null;
defaults: StageConfig;
fields: StageField[];
}
@@ -148,11 +159,17 @@ export interface EnginePreviewRequest {
stages?: Record<string, StageConfig>;
}
export const SCALE_HELP: Record<FieldScale, string> = {
threads: 'A scan context can scale this.',
timeout: 'A scan context can scale this.',
rate: 'A scan context can cap this.'
};
export function basicFields(stage: StageCatalogEntry): StageField[] {
return stage.fields.filter((f) => f.name !== 'enabled' && f.tier !== ADVANCED_TIER);
}
export function advancedFields(stage: StageCatalogEntry): StageField[] {
return stage.fields.filter((f) => f.name !== 'enabled' && f.tier === ADVANCED_TIER);
}
export function stageRate(stage: StageCatalogEntry, intensity: string): number {
return stage.transport?.rates?.[intensity] ?? 0;
}
export const PHASE_LABELS: Record<string, string> = {
discovery: 'Discovery',
@@ -63,9 +63,7 @@ function fieldDetail(field: StageField): string {
}
function fieldInfo(field: StageField): string | undefined {
const parts = [field.description ?? ''];
if (field.scale) parts.push('A scan context can scale this.');
const text = parts.filter(Boolean).join(' ');
const text = field.description ?? '';
return text || undefined;
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest';
import { summarize } from './engine-summary';
import type { StageCatalogEntry } from '$lib/types/scan-engine';
function stage(
name: string,
over: Partial<StageCatalogEntry> & { rates?: Record<string, number | null> }
): StageCatalogEntry {
const { rates, ...rest } = over;
return {
name,
title: name,
description: '',
phase: 'expansion',
level: 0,
applies_to: ['domain'],
tools: [],
api_keys: [],
requires_api_keys: false,
touches_target: true,
launch_fields: [],
group: 'web',
role: 'capability',
consumes: [],
produces: [],
transport: rates
? { tool: 'httpx', rates, threads: { normal: 1, aggressive: 2 }, timeout: 10 }
: null,
defaults: { enabled: true },
fields: [],
...rest
};
}
const catalog = [
stage('http_probe', { rates: { passive: 150, normal: 150, aggressive: 400 } }),
stage('port_scan', { rates: { passive: 1000, normal: 1000, aggressive: 3000 } }),
stage('reverse_dns', {
touches_target: false,
rates: { passive: null, normal: null, aggressive: null }
})
];
describe('summarize', () => {
it('reads the rate off the served transport for the run intensity', () => {
expect(summarize({}, catalog, 'normal').requestsPerSecond).toBe(1150);
expect(summarize({}, catalog, 'aggressive').requestsPerSecond).toBe(3400);
});
it('counts nothing at passive and reports the blocked stages', () => {
const passive = summarize({}, catalog, 'passive');
expect(passive.requestsPerSecond).toBe(0);
expect(passive.footprint).toBe('none');
expect(passive.headline).toContain('2 blocked by passive');
});
it('drops a stage the engine switched off', () => {
const quiet = summarize({ port_scan: { enabled: false } }, catalog, 'normal');
expect(quiet.requestsPerSecond).toBe(150);
expect(quiet.footprint).toBe('quiet');
});
});
+7 -12
View File
@@ -1,4 +1,9 @@
import type { EngineCatalog, StageCatalogEntry, StageConfig } from '$lib/types/scan-engine';
import {
stageRate,
type EngineCatalog,
type StageCatalogEntry,
type StageConfig
} from '$lib/types/scan-engine';
export type Footprint = 'none' | 'quiet' | 'moderate' | 'loud';
@@ -40,16 +45,6 @@ function enabled(stage: StageCatalogEntry, stages: Record<string, StageConfig>):
return Boolean(stages?.[stage.name]?.enabled ?? stage.defaults.enabled);
}
function rateOf(stage: StageCatalogEntry, config: StageConfig): number {
let total = 0;
for (const field of stage.fields) {
if (field.scale !== 'rate') continue;
const value = config?.[field.name] ?? stage.defaults[field.name];
if (typeof value === 'number') total += value;
}
return total;
}
export function summarize(
stages: Record<string, StageConfig>,
catalog: EngineCatalog | StageCatalogEntry[] | null,
@@ -61,7 +56,7 @@ export function summarize(
const running = passiveMode ? active.filter((s) => !s.touches_target) : active;
const loud = running.filter((s) => s.touches_target);
const requestsPerSecond = loud.reduce((n, s) => n + rateOf(s, stages?.[s.name] ?? {}), 0);
const requestsPerSecond = loud.reduce((n, s) => n + stageRate(s, intensity), 0);
const footprint = footprintFor(requestsPerSecond, loud.length > 0);
@@ -189,7 +189,7 @@
}
function toggleSelectAll() {
const all = scanEnginesStore.engines;
const all = scanEnginesStore.engines.filter((e) => !e.builtin);
if (selectedIds.size >= all.length) selectedIds.clear();
else for (const e of all) selectedIds.add(e.id);
}
@@ -270,6 +270,7 @@
const stageCount = $derived(stages.length);
const total = $derived(scanEnginesStore.engines.length);
const selectable = $derived(scanEnginesStore.engines.filter((e) => !e.builtin).length);
</script>
<svelte:head><title>{routeLabels.engines} · reNgine</title></svelte:head>
@@ -471,7 +472,7 @@
<SelectionActionBar selectedCount={selectedIds.size} noun="engine" onClear={clearSelection}>
<Button variant="ghost" size="sm" class="gap-2 font-medium" onclick={toggleSelectAll}>
<ListChecks class="h-3.5 w-3.5 text-muted-foreground" />
{selectedIds.size >= total ? 'Deselect all' : 'Select all'}
{selectedIds.size >= selectable ? 'Deselect all' : 'Select all'}
</Button>
<Button
variant="ghost"
@@ -50,7 +50,7 @@
import type { IconComponent } from '$lib/config/icons';
import { downloadBlob } from '$lib/utilities/download';
import { writeClipboard } from '$lib/utilities/clipboard';
import { phaseLabel } from '$lib/types/scan-engine';
import { CAPABILITY, SUPPORT } from '$lib/utilities/launch-plan';
import type {
Intensity,
ScanEngine,
@@ -156,59 +156,40 @@
).length
);
const stagesByPhase = $derived.by(() => {
function matches(stage: StageCatalogEntry): boolean {
const term = filter.trim().toLowerCase();
return engineCatalogStore.byPhase().map((group) => ({
phase: group.phase,
stages: group.stages.filter((stage) => {
if (
modifiedOnly &&
!Object.keys(overridesOf(parsed?.stages?.[stage.name] ?? {}, stage.defaults)).length
) {
return false;
}
if (!term) return true;
return (
stage.title.toLowerCase().includes(term) ||
stage.name.includes(term) ||
stage.tools.some((t) => t.includes(term)) ||
stage.fields.some((f) => f.name.includes(term) || f.title.toLowerCase().includes(term))
);
})
}));
});
const visibleGroups = $derived(stagesByPhase.filter((g) => g.stages.length > 0));
function levelsOf(stages: StageCatalogEntry[]): StageCatalogEntry[][] {
const out: StageCatalogEntry[][] = [];
for (const stage of stages) {
const last = out[out.length - 1];
if (last && last[0].level === stage.level) last.push(stage);
else out.push([stage]);
if (
modifiedOnly &&
!Object.keys(overridesOf(parsed?.stages?.[stage.name] ?? {}, stage.defaults)).length
) {
return false;
}
return out;
if (!term) return true;
return (
stage.title.toLowerCase().includes(term) ||
stage.name.includes(term) ||
stage.tools.some((t) => t.includes(term)) ||
stage.fields.some((f) => f.name.includes(term) || f.title.toLowerCase().includes(term))
);
}
type RailKind = 'none' | 'solid' | 'dotted';
const stagesByGroup = $derived.by(() =>
engineCatalogStore.byGroup().map((group) => {
const capabilities = group.stages.filter((s) => s.role === CAPABILITY && matches(s));
const support = group.stages.filter((s) => s.role === SUPPORT && matches(s));
return {
key: group.key,
label: group.label,
capabilities,
active: support.filter((s) => s.touches_target && !s.always_on),
automatic: support.filter((s) => !s.touches_target || s.always_on)
};
})
);
function railKinds(
si: number,
level: StageCatalogEntry[],
li: number,
levels: StageCatalogEntry[][],
gi: number,
groups: number
): { up: RailKind; down: RailKind } {
const up = si > 0 ? 'solid' : li > 0 || gi > 0 ? 'dotted' : 'none';
const down =
si < level.length - 1
? 'solid'
: li < levels.length - 1 || gi < groups - 1
? 'dotted'
: 'none';
return { up, down };
}
const visibleGroups = $derived(
stagesByGroup.filter((g) => g.capabilities.length || g.active.length || g.automatic.length)
);
function stageConfig(name: string): StageConfig {
return parsed?.stages?.[name] ?? {};
@@ -499,6 +480,25 @@
<svelte:head><title>{engine?.name ?? routeLabels.engines} · reNgine</title></svelte:head>
{#snippet stageRow(stage: StageCatalogEntry, support: boolean)}
<StageRow
{stage}
config={stageConfig(stage.name)}
open={openStages[stage.name] ?? false}
active={activeStage === stage.name}
applicable={stage.applies_to.includes(lensTargetType)}
blockedByIntensity={blockedByIntensity(stage.name)}
{lensTargetType}
{support}
onToggleOpen={() => {
openStages = { ...openStages, [stage.name]: !openStages[stage.name] };
flashStage(stage.name);
}}
onChange={(field, value) => setStageField(stage.name, field, value)}
onReset={() => resetStage(stage.name)}
/>
{/snippet}
{#snippet controls()}
<section class="controls">
<div class="toolbar">
@@ -561,57 +561,27 @@
{/if}
{#if parsed}
{#each visibleGroups as group, gi (group.phase)}
{@const levels = levelsOf(group.stages)}
<div class="phase" class:linked={gi > 0}>
<header class="phase-head">
{#if gi > 0}
<span class="dotted phase-link" aria-hidden="true"></span>
{#each visibleGroups as group (group.key)}
{@const on = group.capabilities.filter((s) => stageStates[s.name]).length}
<div class="group">
<header class="group-head">
<h2>{group.label}</h2>
{#if group.capabilities.length}
<span class="group-count">{on}/{group.capabilities.length} on</span>
{/if}
<h2>{phaseLabel(group.phase)}</h2>
<span class="phase-count">
{group.stages.filter((s) => stageStates[s.name]).length}/{group.stages.length} on
</span>
</header>
<div class="phase-body">
{#each levels as level, li (level[0].level)}
{#if li > 0}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<div {...props} class="level-gap">
<span class="dotted" aria-hidden="true"></span>
</div>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right" class="text-xs">
Waits for the stages above to finish
</Tooltip.Content>
</Tooltip.Root>
{/if}
<div class="level">
{#each level as stage, si (stage.name)}
{@const rails = railKinds(si, level, li, levels, gi, visibleGroups.length)}
<StageRow
{stage}
config={stageConfig(stage.name)}
open={openStages[stage.name] ?? false}
active={activeStage === stage.name}
railUp={rails.up}
railDown={rails.down}
applicable={stage.applies_to.includes(lensTargetType)}
blockedByIntensity={blockedByIntensity(stage.name)}
{lensTargetType}
onToggleOpen={() => {
openStages = { ...openStages, [stage.name]: !openStages[stage.name] };
flashStage(stage.name);
}}
onChange={(field, value) => setStageField(stage.name, field, value)}
onReset={() => resetStage(stage.name)}
/>
{/each}
</div>
<div class="group-body">
{#each group.capabilities as stage (stage.name)}
{@render stageRow(stage, false)}
{/each}
{#each group.active as stage (stage.name)}
{@render stageRow(stage, true)}
{/each}
{#if group.automatic.length}
<p class="automatic">
Runs automatically: {group.automatic.map((s) => s.title).join(', ')}
</p>
{/if}
</div>
</div>
{/each}
@@ -998,67 +968,40 @@
padding: 8px 2px;
}
.dotted {
display: block;
width: 3px;
background: radial-gradient(
circle,
color-mix(in oklch, var(--muted-foreground) 65%, transparent) 1px,
transparent 1.6px
)
center / 3px 5px repeat-y;
.group + .group {
margin-top: 14px;
}
.phase.linked {
margin-top: 12px;
}
.phase-head {
position: relative;
.group-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
padding: 2px 2px 8px 32px;
padding: 2px 2px 8px 12px;
}
.phase-head .phase-link {
position: absolute;
left: 15px;
top: -12px;
bottom: 0;
}
.phase-head h2 {
.group-head h2 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--muted-foreground);
}
.phase-count {
.group-count {
font-size: 11px;
color: var(--muted-foreground);
font-variant-numeric: tabular-nums;
}
.phase-body {
.group-body {
position: relative;
border: 1px solid var(--border);
border-radius: 0.7rem;
background: var(--card);
overflow: hidden;
}
.level {
position: relative;
}
.level-gap {
position: relative;
height: 14px;
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
background: color-mix(in oklch, var(--muted) 35%, transparent);
}
.level-gap .dotted {
position: absolute;
left: 15px;
top: 0;
bottom: 0;
.automatic {
padding: 8px 14px 9px 32px;
font-size: 11px;
color: var(--muted-foreground);
background: color-mix(in oklch, var(--muted) 30%, transparent);
}
.side {
+36
View File
@@ -0,0 +1,36 @@
"""The built-in engine every project starts with."""
from __future__ import annotations
from shared.enums.scan import Intensity
DEFAULT_ENGINE_NAME = "Default"
DEFAULT_ENGINE_DESCRIPTION = (
"Subdomain discovery on subfinder, port scan, HTTP probe, screenshots, "
"URL discovery and a nuclei vulnerability scan at Normal intensity. Built in."
)
DEFAULT_ENGINE_INTENSITY = Intensity.NORMAL.value
SUBDOMAIN_STAGE = "subdomain_discovery"
VULNERABILITY_STAGE = "vulnerability_scan"
DEFAULT_PASSIVE_SOURCES: tuple[str, ...] = ("subfinder",)
DEFAULT_VULN_SCANNERS: tuple[str, ...] = ("nuclei",)
def default_engine_stages() -> dict[str, dict]:
"""Only the settings that differ from the stage defaults."""
return {
SUBDOMAIN_STAGE: {"passive_tools": list(DEFAULT_PASSIVE_SOURCES)},
VULNERABILITY_STAGE: {"enabled": True, "scanners": list(DEFAULT_VULN_SCANNERS)},
}
__all__ = [
"DEFAULT_ENGINE_DESCRIPTION",
"DEFAULT_ENGINE_INTENSITY",
"DEFAULT_ENGINE_NAME",
"DEFAULT_PASSIVE_SOURCES",
"DEFAULT_VULN_SCANNERS",
"SUBDOMAIN_STAGE",
"VULNERABILITY_STAGE",
"default_engine_stages",
]
+155
View File
@@ -0,0 +1,155 @@
"""Per-tool transport (rate, concurrency, timeout) for each intensity."""
from __future__ import annotations
from dataclasses import asdict, dataclass
from enum import StrEnum
from shared.definitions.constants import MAX_RATE, MAX_THREADS, MAX_TIMEOUT
from shared.enums.scan import Intensity
class TransportTool(StrEnum):
HTTPX = "httpx"
NAABU = "naabu"
NUCLEI = "nuclei"
KATANA = "katana"
FFUF = "ffuf"
DNSX = "dnsx"
BANNER = "banner"
@dataclass(frozen=True)
class Transport:
tool: str
rate: int | None
threads: int
timeout: int
retries: int
def as_dict(self) -> dict:
return asdict(self)
# (rate per second or None for unlimited, concurrency)
_NORMAL: dict[str, tuple[int | None, int]] = {
TransportTool.HTTPX.value: (150, 150),
TransportTool.NAABU.value: (1000, 100),
TransportTool.NUCLEI.value: (150, 25),
TransportTool.KATANA.value: (150, 50),
TransportTool.FFUF.value: (150, 40),
TransportTool.DNSX.value: (None, 30),
TransportTool.BANNER.value: (None, 32),
}
_AGGRESSIVE: dict[str, tuple[int | None, int]] = {
TransportTool.HTTPX.value: (400, 300),
TransportTool.NAABU.value: (3000, 200),
TransportTool.NUCLEI.value: (400, 50),
TransportTool.KATANA.value: (300, 100),
TransportTool.FFUF.value: (400, 80),
TransportTool.DNSX.value: (None, 50),
TransportTool.BANNER.value: (None, 64),
}
PROFILES: dict[str, dict[str, tuple[int | None, int]]] = {
Intensity.PASSIVE.value: _NORMAL,
Intensity.NORMAL.value: _NORMAL,
Intensity.AGGRESSIVE.value: _AGGRESSIVE,
}
TOOL_TIMEOUT: dict[str, int] = {
TransportTool.HTTPX.value: 10,
TransportTool.NAABU.value: 3,
TransportTool.NUCLEI.value: 10,
TransportTool.KATANA.value: 15,
TransportTool.FFUF.value: 8,
TransportTool.DNSX.value: 5,
TransportTool.BANNER.value: 4,
}
TOOL_RETRIES: dict[str, int] = {
TransportTool.NAABU.value: 1,
TransportTool.NUCLEI.value: 1,
}
RATE_TOOLS: tuple[str, ...] = tuple(
tool for tool, (rate, _) in _NORMAL.items() if rate is not None
)
INTENSITY_LABELS: dict[str, str] = {
Intensity.PASSIVE.value: "Passive",
Intensity.NORMAL.value: "Normal",
Intensity.AGGRESSIVE.value: "Aggressive",
}
INTENSITY_HELP: dict[str, str] = {
Intensity.PASSIVE.value: "Public sources only. No traffic is sent to the target.",
Intensity.NORMAL.value: "150 requests per second per tool, 1,000 packets per second for the port scan.",
Intensity.AGGRESSIVE.value: "400 requests per second per tool, 3,000 packets per second for the port scan. Concurrency doubled.",
}
def _clamp(value: int, lo: int, hi: int) -> int:
return max(lo, min(hi, value))
def tool_rate(
tool: str,
intensity: str,
*,
rate_override: int | None = None,
ceiling: int | None = None,
) -> int | None:
"""The rate one tool runs at under this intensity, after the context's say."""
base = PROFILES.get(intensity, _NORMAL).get(tool, (None, 1))[0]
value = rate_override if rate_override is not None else base
if value is None:
return None
if ceiling is not None:
value = min(value, ceiling)
return _clamp(int(value), 1, MAX_RATE)
def transport_for(
tool: str,
intensity: str,
*,
rate_weight: float = 1.0,
thread_weight: float = 1.0,
timeout: int | None = None,
thread_multiplier: float = 1.0,
timeout_multiplier: float = 1.0,
rate_override: int | None = None,
ceiling: int | None = None,
) -> Transport:
profile = PROFILES.get(intensity, _NORMAL)
if tool not in profile:
msg = f"{tool!r} has no transport profile."
raise KeyError(msg)
_, threads = profile[tool]
rate = tool_rate(tool, intensity, rate_override=rate_override, ceiling=ceiling)
if rate is not None:
rate = _clamp(round(rate * rate_weight) or 1, 1, MAX_RATE)
threads = _clamp(
round(threads * thread_weight * thread_multiplier) or 1, 1, MAX_THREADS
)
base_timeout = TOOL_TIMEOUT[tool] if timeout is None else timeout
seconds = _clamp(round(base_timeout * timeout_multiplier) or 1, 1, MAX_TIMEOUT)
return Transport(
tool=tool,
rate=rate,
threads=threads,
timeout=seconds,
retries=TOOL_RETRIES.get(tool, 0),
)
__all__ = [
"INTENSITY_HELP",
"INTENSITY_LABELS",
"PROFILES",
"RATE_TOOLS",
"TOOL_RETRIES",
"TOOL_TIMEOUT",
"Transport",
"TransportTool",
"tool_rate",
"transport_for",
]
+11 -1
View File
@@ -31,6 +31,7 @@ class ScanEngine(SQLModel, table=True):
tool_options: dict = Field(
default_factory=dict, sa_column=Column(JSON, nullable=False)
)
builtin: bool = Field(default=False)
created_at: datetime = Field(default_factory=utc_now)
updated_at: datetime = Field(default_factory=utc_now)
last_used_at: datetime | None = Field(default=None)
@@ -77,6 +78,7 @@ class ScanEngineRead(BaseModel):
yaml_source: str | None
tool_options: dict[str, str] = PydanticField(default_factory=dict)
usage: EngineUsage = PydanticField(default_factory=EngineUsage)
builtin: bool = False
created_at: datetime
updated_at: datetime
last_used_at: datetime | None
@@ -92,12 +94,19 @@ class StageField(BaseModel):
option_labels: dict[str, str] | None = None
minimum: int | None = None
maximum: int | None = None
scale: str | None = None
tier: str = "basic"
widget: str | None = None
kind: str | None = None
launch: bool = False
class StageTransport(BaseModel):
tool: str
rates: dict[str, int | None]
threads: dict[str, int]
timeout: int
class StageCatalogEntry(BaseModel):
name: str
title: str
@@ -115,6 +124,7 @@ class StageCatalogEntry(BaseModel):
role: str
consumes: list[str] = PydanticField(default_factory=list)
produces: list[str] = PydanticField(default_factory=list)
transport: StageTransport | None = None
defaults: dict
fields: list[StageField] = PydanticField(default_factory=list)
+25 -34
View File
@@ -7,11 +7,8 @@ from typing import TYPE_CHECKING, ClassVar
from pydantic import BaseModel, Field, PrivateAttr, ValidationError
from shared.definitions.constants import (
MAX_RATE,
MAX_THREADS,
MAX_TIMEOUT,
)
from shared.definitions.constants import MAX_RATE
from shared.definitions.intensity import tool_rate
from shared.definitions.tools import parse_tool_args
if TYPE_CHECKING:
@@ -246,6 +243,7 @@ class ResolvedScanConfig(BaseModel):
thread_multiplier: float = 1.0
timeout_multiplier: float = 1.0
stages: dict[str, dict] = Field(default_factory=dict)
transports: dict[str, dict] = Field(default_factory=dict)
excluded_subdomains: list[str] = Field(default_factory=list)
excluded_paths: list[str] = Field(default_factory=list)
excluded_ips: list[str] = Field(default_factory=list)
@@ -326,22 +324,6 @@ def _build_headers(engine, ctx) -> tuple[dict[str, str], list[str]]:
return headers, auth_header_names
def _resolve_rate(
base: int, tool: str | None, overrides: dict, ceiling: int | None
) -> int:
value = overrides.get(tool, base) if tool else base
if ceiling is not None:
value = min(value, ceiling)
return _clamp(int(value), 1, MAX_RATE)
def _assert_flags_preserved(stage: str, original: dict, scaled: dict) -> None:
for key, value in original.items():
if isinstance(value, bool) and scaled[key] != value:
msg = f"Resolved enable flag {stage}.{key} diverged from engine."
raise RuntimeError(msg)
def validate_overrides(overrides: dict | None) -> dict[str, dict]:
"""Keep only stage keys that exist and values their own config model accepts."""
from stages.registry import stage_by_name # noqa: PLC0415
@@ -394,7 +376,7 @@ def merge_engine_context(
intensity: str | None = None,
) -> ResolvedScanConfig:
from shared.enums.scan import INTENSITIES, Intensity # noqa: PLC0415
from stages.config import Scale # noqa: PLC0415
from stages.registry import rate_tools # noqa: PLC0415
from stages.registry import stages as stage_specs # noqa: PLC0415
run_intensity = intensity or engine.intensity
@@ -415,7 +397,18 @@ def merge_engine_context(
stored = engine.stages or {}
run_overrides = validate_overrides(overrides)
stages: dict[str, dict] = {}
transports: dict[str, dict] = {}
per_tool_rate_limits: dict[str, int] = {}
for tool in rate_tools():
override = rate_overrides.get(tool)
limit = tool_rate(
tool,
run_intensity,
rate_override=_clamp(int(override), 1, MAX_RATE) if override else None,
ceiling=global_rate_limit_ceiling,
)
if limit is not None:
per_tool_rate_limits[tool] = limit
for spec in stage_specs():
authored = (
@@ -430,18 +423,15 @@ def merge_engine_context(
authored["enabled"] = True
config = spec.config_model(**authored)
values = config.model_dump()
for name, (scale, tool) in spec.config_model.scaled_fields().items():
base = values[name]
if scale is Scale.THREADS:
values[name] = _clamp(int(base * thread_mult) or 1, 1, MAX_THREADS)
elif scale is Scale.TIMEOUT:
values[name] = _clamp(int(base * timeout_mult) or 1, 1, MAX_TIMEOUT)
elif scale is Scale.RATE:
values[name] = _resolve_rate(
base, tool, rate_overrides, global_rate_limit_ceiling
)
per_tool_rate_limits[tool] = values[name]
_assert_flags_preserved(spec.name, config.model_dump(), values)
if spec.transport_tool is not None:
override = rate_overrides.get(spec.transport_tool)
transports[spec.name] = spec.transport(
run_intensity,
thread_multiplier=thread_mult,
timeout_multiplier=timeout_mult,
rate_override=_clamp(int(override), 1, MAX_RATE) if override else None,
ceiling=global_rate_limit_ceiling,
).as_dict()
if passive and spec.touches_target:
values["enabled"] = False
stages[spec.name] = values
@@ -463,6 +453,7 @@ def merge_engine_context(
thread_multiplier=thread_mult,
timeout_multiplier=timeout_mult,
stages=stages,
transports=transports,
excluded_subdomains=excluded_subdomains,
excluded_paths=excluded_paths,
excluded_ips=excluded_ips,
+27
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, ClassVar
from sqlalchemy import update
from shared.definitions.intensity import Transport, transport_for
from shared.definitions.surface import SURFACE_COUNT_COLUMNS, SurfaceDimension
from shared.enums.scan import Phase
from shared.enums.target import TargetType
@@ -105,6 +106,10 @@ class Stage(ABC):
produces: ClassVar[frozenset[str]] = frozenset()
group: ClassVar[str] = ""
role: ClassVar[str] = ""
transport_tool: ClassVar[str | None] = None
rate_weight: ClassVar[float] = 1.0
thread_weight: ClassVar[float] = 1.0
transport_timeout: ClassVar[int | None] = None
config_model: ClassVar[type[StageConfig]] = StageConfig
def __init__(self, session: Session, context: StageContext) -> None:
@@ -118,6 +123,28 @@ class Stage(ABC):
def should_run(self) -> bool:
return self.cfg.enabled
@cached_property
def transport(self) -> Transport:
"""Rate, concurrency and timeout for this stage's tool under the run's intensity."""
stored = (self.ctx.resolved.transports or {}).get(self.name)
if stored:
return Transport(**stored)
return self.default_transport(self.ctx.resolved.intensity)
@classmethod
def default_transport(cls, intensity: str, **scaling) -> Transport:
if cls.transport_tool is None:
msg = f"{cls.name} declares no transport tool."
raise RuntimeError(msg)
return transport_for(
cls.transport_tool,
intensity,
rate_weight=cls.rate_weight,
thread_weight=cls.thread_weight,
timeout=cls.transport_timeout,
**scaling,
)
@abstractmethod
def run(self) -> StageResult: ...
+15 -47
View File
@@ -5,36 +5,24 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from shared.definitions.constants import MAX_RATE, MAX_THREADS, MAX_TIMEOUT
from shared.definitions.wordlists import DEFAULT_WORDLIST, MAX_SLUG_LENGTH
class Scale(StrEnum):
THREADS = "threads"
TIMEOUT = "timeout"
RATE = "rate"
class FieldTier(StrEnum):
BASIC = "basic"
ADVANCED = "advanced"
def threads(default: int, *, title: str, description: str = "") -> Any:
return Field(
default,
ge=1,
le=MAX_THREADS,
title=title,
description=description,
json_schema_extra={"scale": Scale.THREADS.value},
)
ADVANCED: dict[str, str] = {"tier": FieldTier.ADVANCED.value}
def timeout(default: int, *, title: str, description: str = "") -> Any:
return Field(
default,
ge=1,
le=MAX_TIMEOUT,
title=title,
description=description,
json_schema_extra={"scale": Scale.TIMEOUT.value},
)
def advanced(default: Any = None, **kwargs: Any) -> Any:
"""A field behind the stage's Advanced disclosure."""
extra = dict(kwargs.pop("json_schema_extra", None) or {})
extra.update(ADVANCED)
if "default_factory" in kwargs:
return Field(json_schema_extra=extra, **kwargs)
return Field(default, json_schema_extra=extra, **kwargs)
def wordlist(kind: str, *, title: str, description: str = "") -> Any:
@@ -48,17 +36,6 @@ def wordlist(kind: str, *, title: str, description: str = "") -> Any:
)
def rate(default: int, *, tool: str, title: str, description: str = "") -> Any:
return Field(
default,
ge=1,
le=MAX_RATE,
title=title,
description=description,
json_schema_extra={"scale": Scale.RATE.value, "tool": tool},
)
def share_rate(rate: int, processes: int) -> int:
"""One process's share of the stage's rate budget."""
return max(1, rate // max(1, processes))
@@ -72,19 +49,10 @@ class StageConfig(BaseModel):
)
@classmethod
def scaled_fields(cls) -> dict[str, tuple[Scale, str | None]]:
out: dict[str, tuple[Scale, str | None]] = {}
def tiers(cls) -> dict[str, str]:
out: dict[str, str] = {}
for name, field in cls.model_fields.items():
extra = field.json_schema_extra
if not isinstance(extra, dict) or "scale" not in extra:
continue
out[name] = (Scale(extra["scale"]), extra.get("tool"))
tier = extra.get("tier") if isinstance(extra, dict) else None
out[name] = tier or FieldTier.BASIC.value
return out
@classmethod
def rate_tools(cls) -> set[str]:
return {
tool
for scale, tool in cls.scaled_fields().values()
if scale is Scale.RATE and tool
}
+8 -11
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pydantic import Field
from shared.definitions.wordlists import WordlistKind
from stages.config import StageConfig, rate, threads, timeout, wordlist
from stages.config import StageConfig, advanced, wordlist
class ContentDiscoveryConfig(StageConfig):
@@ -24,13 +24,6 @@ class ContentDiscoveryConfig(StageConfig):
title="Words to try",
description="Words tried per site, from the top of the list.",
)
max_hosts: int = Field(
default=25,
ge=1,
le=1_000,
title="Sites to guess against",
description="Sites tried. Sites that answered are picked first.",
)
max_minutes: int = Field(
default=20,
ge=1,
@@ -38,6 +31,10 @@ class ContentDiscoveryConfig(StageConfig):
title="Time budget (minutes)",
description="The run stops after this many minutes and reports partial coverage.",
)
threads: int = threads(40, title="Threads")
rate: int = rate(50, tool="ffuf", title="Requests/s")
timeout: int = timeout(8, title="Request timeout (s)")
max_hosts: int = advanced(
25,
ge=1,
le=1_000,
title="Sites to guess against",
description="Sites tried. Sites that answered are picked first.",
)
+10 -3
View File
@@ -10,6 +10,7 @@ from pathlib import Path
from sqlalchemy import select
from shared.definitions.endpoints import EndpointSource
from shared.definitions.intensity import TransportTool
from shared.definitions.surface import SurfaceDimension
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
from shared.logging import get_logger
@@ -17,6 +18,7 @@ from shared.models.http_asset import HttpAsset
from shared.services import endpoint_inventory
from shared.services.endpoint_inventory import EndpointObservation
from shared.services.endpoint_noise import NoisePolicy
from shared.services.scope_filter import matches_any
from shared.services.wordlists import WordlistError, read_words
from stages.base import ALL_TARGETS, Stage, StageResult
from stages.config import share_rate
@@ -49,6 +51,8 @@ class ContentDiscoveryStage(Stage):
produces = frozenset({AssetKind.ENDPOINTS.value})
applies_to = ALL_TARGETS
tools = ("ffuf",)
transport_tool = TransportTool.FFUF.value
rate_weight = 1 / 3
touches_target = True
config_model = ContentDiscoveryConfig
launch_fields = (
@@ -86,9 +90,9 @@ class ContentDiscoveryStage(Stage):
net = self.net_options()
args = {
"wordlist": word_file,
"threads": cfg.threads,
"rate": share_rate(cfg.rate, workers),
"request_timeout": cfg.timeout,
"threads": self.transport.threads,
"rate": share_rate(self.transport.rate or 1, workers),
"request_timeout": self.transport.timeout,
"proxy_url": net.proxy_url,
"headers": net.headers,
"recorder": self.ctx.recorder,
@@ -202,6 +206,9 @@ class ContentDiscoveryStage(Stage):
except WordlistError as exc:
yield None, str(exc)
return
excluded = self.ctx.resolved.excluded_paths or []
if excluded:
words = [w for w in words if not matches_any(f"/{w.lstrip('/')}", excluded)]
if not words:
yield None, f"{label} has no usable words"
return
+20 -88
View File
@@ -2,20 +2,14 @@ from __future__ import annotations
from pydantic import Field, field_validator
from shared.definitions.scan_surface import (
BASE_MAX_DEPTH,
MAX_BASES_PER_ORIGIN,
MAX_REQUESTS,
MAX_REQUESTS_PER_ORIGIN,
)
from shared.definitions.scan_surface import MAX_REQUESTS, MAX_REQUESTS_PER_ORIGIN
from shared.definitions.vulnerabilities import (
DEFAULT_SEVERITIES,
MAX_FINDINGS_PER_SCAN,
SEVERITY_LABELS,
SEVERITY_ORDER,
reject_unknown,
)
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig, advanced
class DastScanConfig(StageConfig):
@@ -43,58 +37,15 @@ class DastScanConfig(StageConfig):
"widget": "severity",
},
)
max_requests_per_origin: int = Field(
default=MAX_REQUESTS_PER_ORIGIN,
ge=1,
le=1000,
title="Requests per origin",
description="Parameter sets fuzzed per origin at most.",
)
max_requests: int = Field(
default=MAX_REQUESTS,
ge=1,
le=20000,
title="Request budget",
description="Parameter sets fuzzed in one scan at most.",
)
directories: bool = Field(
default=False,
title="Directory exposure checks",
description="Run the exposure checks under each discovered directory.",
)
max_dirs_per_origin: int = Field(
default=MAX_BASES_PER_ORIGIN,
ge=1,
le=200,
title="Directories per origin",
)
dir_depth: int = Field(
default=BASE_MAX_DEPTH,
ge=1,
le=3,
title="Directory depth",
description="Deepest directory level checked.",
)
fuzz_param_frequency: int = Field(
default=10,
ge=1,
le=100,
title="Parameter patience",
description="Sightings of one parameter name without a match before it is skipped on that host.",
)
rate: int = rate(50, tool="nuclei", title="Request rate (rps)")
threads: int = threads(
25, title="Concurrency", description="Checks running in parallel."
)
bulk_size: int = Field(default=25, ge=1, le=500, title="Requests per check")
timeout: int = timeout(10, title="Request timeout (s)")
retries: int = Field(default=1, ge=0, le=5, title="Retries")
max_host_error: int = Field(
default=30,
ge=1,
le=500,
title="Host error budget",
description="Errors a host may return before it is dropped.",
headless: bool = Field(
default=False,
title="Browser fuzzing",
description="Run the fuzzing checks that need a rendered page. Most DAST checks do.",
)
max_minutes: int = Field(
default=30,
@@ -103,43 +54,24 @@ class DastScanConfig(StageConfig):
title="Time budget (min)",
description="Stop starting batches after this many minutes. 0 means no limit.",
)
waf_rate_divisor: int = Field(
default=3,
max_requests_per_origin: int = advanced(
MAX_REQUESTS_PER_ORIGIN,
ge=1,
le=20,
title="WAF rate divisor",
description="Hosts behind a WAF or CDN are fuzzed at the request rate divided by this. 1 disables.",
le=1000,
title="Requests per origin",
description="Parameter sets fuzzed per origin at most.",
)
honeypot_threshold: int = Field(default=0, ge=0, le=200, title="Honeypot threshold")
headless: bool = Field(
default=False,
title="Browser fuzzing",
description="Run the fuzzing checks that need a rendered page. Most DAST checks do.",
max_requests: int = advanced(
MAX_REQUESTS,
ge=1,
le=20000,
title="Request budget",
description="Parameter sets fuzzed in one scan at most.",
)
interactsh: bool = Field(
default=False,
interactsh: bool = advanced(
False,
title="Out-of-band testing",
description="Detect blind injection through callbacks to an external server.",
)
interactsh_server: str = Field(
default="", max_length=200, title="Self-hosted OAST server"
)
oast_wait_minutes: int = Field(
default=5, ge=0, le=120, title="Keep listening (minutes)"
)
store_evidence: bool = Field(
default=True,
title="Store request and response",
description="Keep the request and response that produced each finding.",
)
max_evidence_recovery: int = Field(
default=0, ge=0, le=2000, title="Evidence recovery budget"
)
max_findings: int = Field(
default=MAX_FINDINGS_PER_SCAN,
ge=1,
le=MAX_FINDINGS_PER_SCAN,
title="Finding budget",
description="Detect blind injection through callbacks. The server is set on the vulnerability scan.",
)
@field_validator("severities")
+9 -9
View File
@@ -62,8 +62,8 @@ class DalfoxScanner(VulnScanner):
severities=list(ctx.cfg.severities),
hosts_total=len(plan.requests),
hosts_covered=len(reflecting),
rate_limit=ctx.cfg.rate,
concurrency=ctx.cfg.threads,
rate_limit=ctx.transport.rate,
concurrency=ctx.transport.threads,
)
if not reflecting:
coverage.status = CoverageStatus.COMPLETED.value
@@ -93,8 +93,8 @@ class DalfoxScanner(VulnScanner):
return []
try:
client = HttpxClient(
rate_limit=ctx.cfg.rate,
threads=ctx.cfg.threads,
rate_limit=ctx.transport.rate,
threads=ctx.transport.threads,
timeout=_PROBE_TIMEOUT,
proxy_url=ctx.net.proxy_url,
headers=ctx.net.headers,
@@ -121,10 +121,10 @@ class DalfoxScanner(VulnScanner):
ctx = self.ctx
secrets_list = [v for v in (ctx.net.headers or {}).values() if v]
options = DalfoxOptions(
workers=ctx.cfg.threads,
rate=ctx.cfg.rate,
timeout=ctx.cfg.timeout,
retries=ctx.cfg.retries,
workers=ctx.transport.threads,
rate=ctx.transport.rate or 1,
timeout=ctx.transport.timeout,
retries=ctx.transport.retries,
proxy_url=ctx.net.proxy_url,
headers=dict(ctx.net.headers or {}),
follow_redirects=bool(ctx.resolved.follow_redirects),
@@ -142,7 +142,7 @@ class DalfoxScanner(VulnScanner):
return coverage
def _on_finding(finding) -> None:
if ctx.cfg.store_evidence:
if ctx.keep_evidence():
finding.request = redact_secrets(finding.request, secrets_list)
finding.response = redact_secrets(finding.response, secrets_list)
else:
+8 -3
View File
@@ -26,6 +26,7 @@ from shared.services.scan_surface import (
from shared.services.vuln_templates import selection_predicate
from stages.vulnerability_scan.scanners.base import ScannerResult
from stages.vulnerability_scan.scanners.nuclei import (
WAF_RATE_DIVISOR,
Job,
NucleiScanner,
_by_lane,
@@ -36,6 +37,7 @@ from tools.nuclei.parser import Finding
_EXPOSURE_SET = "exposure"
_REQUESTS_PER_INVOCATION = 100
FUZZ_PARAM_FREQUENCY = 10
_BASE_URL = "{{BaseURL}}"
@@ -72,6 +74,10 @@ def exposure_templates(session, cfg) -> list[VulnTemplate]:
class NucleiDastScanner(NucleiScanner):
"""The same runner, handed request and directory items instead of roots."""
honeypot_threshold = 0
evidence_recovery = 0
fuzz_param_frequency = FUZZ_PARAM_FREQUENCY
def run(self) -> ScannerResult:
ctx = self.ctx
ok, reason = self.availability()
@@ -106,7 +112,6 @@ class NucleiDastScanner(NucleiScanner):
def _schedule_requests(
self, plan: SurfacePlan, fuzz, exposure
) -> dict[str, list[Job]]:
cfg = self.ctx.cfg
rates = self._rates(plan)
lanes: dict[str, list[Job]] = {}
counter: dict[str, int] = {}
@@ -118,7 +123,7 @@ class NucleiDastScanner(NucleiScanner):
if fuzz.paths and plan.requests:
files = fuzz.files(fuzz.rows)
for lane, wanted in _by_lane(plan.requests, cfg.waf_rate_divisor).items():
for lane, wanted in _by_lane(plan.requests, WAF_RATE_DIVISOR).items():
grouped: list[SurfaceItem] = [i for g in by_origin(wanted) for i in g]
for items in chunk(grouped, _REQUESTS_PER_INVOCATION):
_push(
@@ -136,7 +141,7 @@ class NucleiDastScanner(NucleiScanner):
if exposure.paths and plan.bases:
files = exposure.files(exposure.rows)
per_host = cost(exposure.rows)
for lane, wanted in _by_lane(plan.bases, cfg.waf_rate_divisor).items():
for lane, wanted in _by_lane(plan.bases, WAF_RATE_DIVISOR).items():
for items in batches(wanted, per_host, rates[lane]):
_push(
Job(
+13 -4
View File
@@ -2,9 +2,15 @@ from __future__ import annotations
import uuid
from shared.definitions.scan_surface import SurfaceClass
from shared.definitions.intensity import TransportTool
from shared.definitions.scan_surface import (
BASE_MAX_DEPTH,
MAX_BASES_PER_ORIGIN,
SurfaceClass,
)
from shared.definitions.surface import SurfaceDimension
from shared.definitions.vulnerabilities import (
MAX_FINDINGS_PER_SCAN,
SEVERITY_LABELS,
CoverageStatus,
Severity,
@@ -55,6 +61,8 @@ class DastScanStage(Stage):
produces = frozenset({AssetKind.VULNERABILITIES.value})
applies_to = ALL_TARGETS
tools = ("nuclei",)
transport_tool = TransportTool.NUCLEI.value
rate_weight = 1 / 3
touches_target = True
config_model = DastScanConfig
launch_fields = ("enabled", "severities", "directories", "headless", "max_minutes")
@@ -76,8 +84,8 @@ class DastScanStage(Stage):
max_per_origin=cfg.max_requests_per_origin,
max_total=cfg.max_requests,
bases=cfg.directories,
max_bases_per_origin=cfg.max_dirs_per_origin,
base_depth=cfg.dir_depth,
max_bases_per_origin=MAX_BASES_PER_ORIGIN,
base_depth=BASE_MAX_DEPTH,
)
scan_surface.write(
self.session,
@@ -99,7 +107,7 @@ class DastScanStage(Stage):
def _store(findings: list) -> int:
self._check_abort()
room = cfg.max_findings - len(stored)
room = MAX_FINDINGS_PER_SCAN - len(stored)
if room <= 0:
return 0
written = vuln_inventory.upsert(
@@ -137,6 +145,7 @@ class DastScanStage(Stage):
target_id=self.ctx.target_id,
project_id=self.ctx.project_id,
cfg=cfg,
transport=self.transport,
resolved=self.ctx.resolved,
net=self.net_options(),
surface=plan,
+11 -16
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pydantic import Field
from shared.definitions.endpoints import DEFAULT_PROBE_CAP
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig, advanced
class EndpointProbeConfig(StageConfig):
@@ -12,23 +12,18 @@ class EndpointProbeConfig(StageConfig):
title="Verify endpoints",
description="Request the discovered URLs and record each status.",
)
threads: int = threads(40, title="Threads")
timeout: int = timeout(10, title="Timeout (s)")
rate: int = rate(150, tool="httpx", title="Requests/s")
max_urls: int = Field(
default=DEFAULT_PROBE_CAP,
skip_static: bool = advanced(
True,
title="Skip images and media",
description="Skip images, stylesheets, fonts and other static files.",
)
max_urls: int = advanced(
DEFAULT_PROBE_CAP,
ge=0,
le=100000,
title="URLs to verify",
description="Endpoints requested. The rest stay unverified.",
)
skip_static: bool = Field(
default=True,
title="Skip images and media",
description="Skip images, stylesheets, fonts and other static files.",
)
follow_redirects: bool = Field(
default=False,
title="Follow redirects",
description="Follow 3xx redirects.",
)
FOLLOW_REDIRECTS = False
+8 -5
View File
@@ -7,6 +7,7 @@ from sqlalchemy import cast, func, select
from sqlalchemy.dialects.postgresql import JSONB
from shared.definitions.endpoints import PROBE_COVERAGE_SOURCE, STATIC_CLASSES
from shared.definitions.intensity import TransportTool
from shared.definitions.surface import SurfaceDimension
from shared.definitions.vulnerabilities import CoverageStatus
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
@@ -19,7 +20,7 @@ from shared.services.endpoint_judge import Fingerprint
from shared.services.scope_filter import matches_any
from shared.utils.datetime import utc_now
from stages.base import ALL_TARGETS, Stage, StageResult
from stages.endpoint_probe.config import EndpointProbeConfig
from stages.endpoint_probe.config import FOLLOW_REDIRECTS, EndpointProbeConfig
from tools.httpx.client import HttpxClient, HttpxError
from tools.httpx.parser import parse_httpx_record
@@ -42,6 +43,8 @@ class EndpointProbeStage(Stage):
consumes = frozenset({AssetKind.ENDPOINTS.value})
applies_to = ALL_TARGETS
tools = ("httpx",)
transport_tool = TransportTool.HTTPX.value
thread_weight = 4 / 15
config_model = EndpointProbeConfig
launch_fields = ("enabled", "max_urls")
@@ -63,13 +66,13 @@ class EndpointProbeStage(Stage):
try:
client = HttpxClient(
rate_limit=cfg.rate,
threads=cfg.threads,
timeout=cfg.timeout,
rate_limit=self.transport.rate,
threads=self.transport.threads,
timeout=self.transport.timeout,
proxy_url=net.proxy_url,
headers=net.headers,
probe_scheme=net.probe_scheme,
follow_redirects=self.follow_redirects(cfg.follow_redirects),
follow_redirects=self.follow_redirects(FOLLOW_REDIRECTS),
recorder=self.ctx.recorder,
extra_args=self.ctx.resolved.tool_args("httpx"),
)
+1 -4
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig
class HostDiscoveryConfig(StageConfig):
@@ -11,6 +11,3 @@ class HostDiscoveryConfig(StageConfig):
title="Find live hosts",
description="Sweep a netblock for responsive hosts before port scanning.",
)
rate: int = rate(150, tool="naabu", title="Packet rate (pps)")
threads: int = threads(30, title="Concurrency")
timeout: int = timeout(5, title="Timeout (s)")
+13 -4
View File
@@ -2,9 +2,11 @@ from __future__ import annotations
from sqlalchemy import select
from shared.definitions.intensity import TransportTool
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
from shared.logging import get_logger
from shared.models.ip_address import IpAddress
from shared.services.scope_filter import ip_excluded
from stages.base import RANGE_TARGETS, Stage, StageResult
from stages.host_discovery.config import HostDiscoveryConfig
from tools.naabu.client import NaabuClient, NaabuError, NaabuOptions
@@ -25,11 +27,14 @@ class HostDiscoveryStage(Stage):
produces = frozenset({AssetKind.ADDRESSES.value})
applies_to = RANGE_TARGETS
tools = ("naabu",)
transport_tool = TransportTool.NAABU.value
rate_weight = 0.15
thread_weight = 0.3
transport_timeout = 5
config_model = HostDiscoveryConfig
def run(self) -> StageResult:
self._check_abort()
cfg = self.cfg
net = self.net_options()
rows = list(
self.session.execute(
@@ -38,15 +43,19 @@ class HostDiscoveryStage(Stage):
.scalars()
.all()
)
excluded = self.ctx.resolved.excluded_ips or []
if excluded:
rows = [row for row in rows if not ip_excluded(row.ip, excluded)]
if not rows:
return StageResult(counts={"alive": 0})
try:
client = NaabuClient(
options=NaabuOptions(
rate=cfg.rate,
concurrency=cfg.threads,
timeout=cfg.timeout,
rate=self.transport.rate or 1,
concurrency=self.transport.threads,
timeout=self.transport.timeout,
retries=self.transport.retries,
proxy_url=net.proxy_url,
extra_args=self.ctx.resolved.tool_args("naabu"),
),
+7 -23
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig, advanced
class HttpProbeConfig(StageConfig):
@@ -11,28 +11,12 @@ class HttpProbeConfig(StageConfig):
title="Probe HTTP services",
description="Fingerprint every host and port for live HTTP, technologies and titles.",
)
threads: int = threads(150, title="Threads")
timeout: int = timeout(10, title="Timeout (s)")
rate: int = rate(150, tool="httpx", title="Requests/s")
follow_redirects: bool = Field(
default=True,
title="Follow redirects",
description="Follow 3xx to the final URL.",
)
probe_open_ports: bool = Field(
default=True,
title="Probe open ports",
description="Probe the ports the port scan found, not only 80 and 443.",
)
probe_all_ports: bool = Field(
default=False,
probe_all_ports: bool = advanced(
False,
title="Include non-web ports",
description="Also probe ports classed as non-HTTP services.",
)
max_ports_per_host: int = Field(
default=25,
ge=1,
le=1000,
title="Ports per host",
description="Cap the ports probed on any one host.",
)
FOLLOW_REDIRECTS = True
MAX_PORTS_PER_HOST = 25
+12 -9
View File
@@ -5,6 +5,7 @@ from collections.abc import Iterable
from sqlalchemy import bindparam, delete, select, update
from sqlalchemy.orm import defer
from shared.definitions.intensity import TransportTool
from shared.definitions.ports import (
DEFAULT_WEB_PORTS,
PortSource,
@@ -25,7 +26,11 @@ from shared.utils.datetime import utc_now
from shared.utils.net import host_port
from shared.utils.software import parse_banner
from stages.base import Stage, StageResult
from stages.http_probe.config import HttpProbeConfig
from stages.http_probe.config import (
FOLLOW_REDIRECTS,
MAX_PORTS_PER_HOST,
HttpProbeConfig,
)
from tools.httpx.client import HttpxClient, HttpxError
from tools.httpx.parser import parse_httpx_record
@@ -93,11 +98,11 @@ class HttpProbeStage(Stage):
consumes = frozenset({AssetKind.HOSTS.value, AssetKind.ADDRESSES.value})
produces = frozenset({AssetKind.HTTP_ASSETS.value})
tools = ("httpx",)
transport_tool = TransportTool.HTTPX.value
config_model = HttpProbeConfig
def run(self) -> StageResult:
self._check_abort()
cfg = self.cfg
net = self.net_options()
targets = self._build_targets()
if not targets:
@@ -105,13 +110,13 @@ class HttpProbeStage(Stage):
try:
client = HttpxClient(
rate_limit=cfg.rate,
threads=cfg.threads,
timeout=cfg.timeout,
rate_limit=self.transport.rate,
threads=self.transport.threads,
timeout=self.transport.timeout,
proxy_url=net.proxy_url,
headers=net.headers,
probe_scheme=net.probe_scheme,
follow_redirects=self.follow_redirects(cfg.follow_redirects),
follow_redirects=self.follow_redirects(FOLLOW_REDIRECTS),
recorder=self.ctx.recorder,
extra_args=self.ctx.resolved.tool_args("httpx"),
)
@@ -146,8 +151,6 @@ class HttpProbeStage(Stage):
def _port_map(self) -> dict[str, set[int]]:
"""Open ports per address, filtered to what can plausibly answer HTTP."""
if not self.cfg.probe_open_ports:
return {}
rows = self.session.execute(
select(Port.ip, Port.number, Port.service_name, Port.service_class).where(
Port.scan_id == self.ctx.scan_id
@@ -167,7 +170,7 @@ class HttpProbeStage(Stage):
for ip in ips or []:
ports |= port_map.get(ip, set())
ordered = sorted(ports, key=lambda p: (p not in DEFAULT_WEB_PORTS, p))
return ordered[: self.cfg.max_ports_per_host]
return ordered[:MAX_PORTS_PER_HOST]
def _build_targets(self) -> list[str]:
target_type = self.ctx.target_type
+8 -26
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, threads, timeout
from stages.config import StageConfig, advanced
class NetblockSweepConfig(StageConfig):
@@ -11,33 +11,15 @@ class NetblockSweepConfig(StageConfig):
title="Netblock sweep",
description="Resolve reverse DNS across the network ranges the organisation announces.",
)
min_addresses: int = Field(
default=2,
ge=1,
le=100,
title="Addresses before a network counts",
description="Minimum number of the scan's addresses a network must hold to count as owned.",
)
max_asn_addresses: int = Field(
default=65536,
ge=256,
le=1048576,
title="Largest network an organisation may own",
description="Networks announcing more addresses than this are excluded.",
)
min_share: int = Field(
default=10,
ge=1,
le=100,
title="Share of the estate (%)",
description="Minimum share of the scan's addresses a network must hold.",
)
max_sweep: int = Field(
default=8192,
max_sweep: int = advanced(
8192,
ge=256,
le=65536,
title="Address budget",
description="Addresses resolved at most. A larger range is reported as partial.",
)
dns_threads: int = threads(50, title="DNS threads")
dns_timeout: int = timeout(5, title="DNS timeout (s)")
MIN_ADDRESSES = 2
MIN_SHARE = 10
MAX_ASN_ADDRESSES = 65536
+24 -20
View File
@@ -5,16 +5,22 @@ from dataclasses import dataclass, field
from sqlalchemy import select, text
from shared.definitions.intensity import TransportTool
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
from shared.enums.subdomain import SubdomainSource
from shared.logging import get_logger
from shared.models.subdomain import Subdomain
from shared.services.ip_asn import ranges_ready
from shared.services.ip_inventory import collect_ips
from shared.services.scope_filter import ip_excluded
from shared.services.scope_filter import ip_excluded, matches_any
from shared.utils.datetime import utc_now
from stages.base import DOMAIN_TARGETS, Stage, StageResult
from stages.netblock_sweep.config import NetblockSweepConfig
from stages.netblock_sweep.config import (
MAX_ASN_ADDRESSES,
MIN_ADDRESSES,
MIN_SHARE,
NetblockSweepConfig,
)
from stages.subdomain.parser import in_scope, normalize_host, passes_included
from tools.dnsx.client import DnsxClient, DnsxError
from tools.dnsx.parser import parse_dnsx_jsonl
@@ -65,6 +71,8 @@ class NetblockSweepStage(Stage):
produces = frozenset({AssetKind.HOSTS.value})
applies_to = DOMAIN_TARGETS
tools = ("dnsx",)
transport_tool = TransportTool.DNSX.value
thread_weight = 5 / 3
touches_target = False
config_model = NetblockSweepConfig
@@ -83,7 +91,7 @@ class NetblockSweepStage(Stage):
if not ips:
return StageResult(counts={"hosts": 0})
owned, rejected = self._owned_networks(ips, cfg)
owned, rejected = self._owned_networks(ips)
if not owned:
note = (
f"No network attributed. {rejected} candidate networks were too "
@@ -100,7 +108,7 @@ class NetblockSweepStage(Stage):
label = ", ".join(f"AS{n.asn}" for n in owned)
self.emit_progress(f"sweeping {len(addresses):,} addresses in {label}")
names, note = self._sweep(addresses, cfg)
names, note = self._sweep(addresses)
in_scope_names, foreign = self._scope(names)
added = self._persist(in_scope_names)
@@ -131,16 +139,14 @@ class NetblockSweepStage(Stage):
partial=bool(note) or truncated,
)
def _owned_networks(
self, ips: list[str], cfg: NetblockSweepConfig
) -> tuple[list[Network], int]:
def _owned_networks(self, ips: list[str]) -> tuple[list[Network], int]:
"""ASNs meeting the share and size thresholds."""
rows = self.session.execute(_ASN_SQL, {"ips": ips}).all()
client = RIPEStatClient()
owned: list[Network] = []
rejected = 0
for asn, as_name, hosts in rows:
if hosts < cfg.min_addresses or hosts * 100 < len(ips) * cfg.min_share:
if hosts < MIN_ADDRESSES or hosts * 100 < len(ips) * MIN_SHARE:
continue
self._check_abort()
net = Network(asn=int(asn), name=as_name or "", hosts=int(hosts))
@@ -159,7 +165,7 @@ class NetblockSweepStage(Stage):
continue
net.prefixes.append(str(block))
net.addresses += block.num_addresses
if not net.prefixes or net.addresses > cfg.max_asn_addresses:
if not net.prefixes or net.addresses > MAX_ASN_ADDRESSES:
rejected += 1
continue
owned.append(net)
@@ -184,11 +190,11 @@ class NetblockSweepStage(Stage):
return out, True
return out, False
def _client(self, cfg: NetblockSweepConfig, count: int) -> DnsxClient | None:
def _client(self, count: int) -> DnsxClient | None:
try:
return DnsxClient(
timeout=max(_MIN_BUDGET, count // _FLOOR_RATE),
threads=cfg.dns_threads,
threads=self.transport.threads,
recorder=self.ctx.recorder,
extra_args=self.ctx.resolved.tool_args("dnsx"),
)
@@ -196,11 +202,9 @@ class NetblockSweepStage(Stage):
logger.warning("dnsx unavailable, skipping netblock sweep")
return None
def _sweep(
self, addresses: list[str], cfg: NetblockSweepConfig
) -> tuple[dict[str, str], str | None]:
def _sweep(self, addresses: list[str]) -> tuple[dict[str, str], str | None]:
"""PTR across the range, then forward-confirm each name resolves back into it."""
client = self._client(cfg, len(addresses))
client = self._client(len(addresses))
if client is None:
return {}, f"dnsx unavailable. {len(addresses):,} addresses were not swept."
@@ -218,15 +222,13 @@ class NetblockSweepStage(Stage):
note = f"dnsx {kind}. {len(addresses):,} addresses were only partly swept."
if found:
confirmed = self._forward_confirm(list(found), set(addresses), cfg)
confirmed = self._forward_confirm(list(found), set(addresses))
found = {name: ip for name, ip in found.items() if name in confirmed}
return found, note
def _forward_confirm(
self, names: list[str], swept: set[str], cfg: NetblockSweepConfig
) -> set[str]:
def _forward_confirm(self, names: list[str], swept: set[str]) -> set[str]:
"""Names whose forward record points back into the swept range."""
client = self._client(cfg, len(names))
client = self._client(len(names))
if client is None:
return set(names)
result = client.query(names, record_types=["a"])
@@ -268,6 +270,7 @@ class NetblockSweepStage(Stage):
source = SubdomainSource.NETBLOCK.value
now = utc_now()
added = 0
excluded = self.ctx.resolved.excluded_subdomains or []
for name, ip in sorted(names.items()):
row = existing.get(name)
if row is not None:
@@ -284,6 +287,7 @@ class NetblockSweepStage(Stage):
sources=[source],
resolved_ips=[ip],
is_active=True,
is_excluded=matches_any(name, excluded),
discovered_at=now,
)
)
+6 -13
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig, advanced
class OriginProbeConfig(StageConfig):
@@ -11,20 +11,13 @@ class OriginProbeConfig(StageConfig):
title="Probe addresses directly",
description="Request each address by IP and record what it serves without a hostname.",
)
threads: int = threads(30, title="Threads")
timeout: int = timeout(10, title="Timeout (s)")
rate: int = rate(150, tool="httpx", title="Requests/s")
max_addresses: int = Field(
default=2048,
max_addresses: int = advanced(
2048,
ge=1,
le=65536,
title="Address budget",
description="Stop after this many addresses.",
)
max_ports_per_address: int = Field(
default=6,
ge=1,
le=100,
title="Ports per address",
description="Cap the web ports requested on any one address.",
)
MAX_PORTS_PER_ADDRESS = 6
+8 -5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from sqlalchemy import select
from shared.definitions.intensity import TransportTool
from shared.definitions.ports import DEFAULT_WEB_PORTS, ServiceClass
from shared.definitions.surface import SurfaceDimension
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
@@ -14,7 +15,7 @@ from shared.services.scope_filter import ip_excluded
from shared.utils.datetime import utc_now
from shared.utils.net import host_port
from stages.base import ALL_TARGETS, Stage, StageResult
from stages.origin_probe.config import OriginProbeConfig
from stages.origin_probe.config import MAX_PORTS_PER_ADDRESS, OriginProbeConfig
from stages.origin_probe.finding import origin_finding
from tools.httpx.client import HttpxClient, HttpxError
from tools.httpx.parser import parse_httpx_record
@@ -39,6 +40,8 @@ class OriginProbeStage(Stage):
consumes = frozenset({AssetKind.PORTS.value})
applies_to = ALL_TARGETS
tools = ("httpx",)
transport_tool = TransportTool.HTTPX.value
thread_weight = 0.2
config_model = OriginProbeConfig
def run(self) -> StageResult:
@@ -51,9 +54,9 @@ class OriginProbeStage(Stage):
try:
client = HttpxClient(
rate_limit=cfg.rate,
threads=cfg.threads,
timeout=cfg.timeout,
rate_limit=self.transport.rate,
threads=self.transport.threads,
timeout=self.transport.timeout,
proxy_url=net.proxy_url,
headers=net.headers,
probe_scheme=net.probe_scheme,
@@ -129,7 +132,7 @@ class OriginProbeStage(Stage):
targets: list[str] = []
for ip in list(by_ip)[: cfg.max_addresses]:
ports = sorted(by_ip[ip], key=lambda p: (p not in DEFAULT_WEB_PORTS, p))[
: cfg.max_ports_per_address
:MAX_PORTS_PER_ADDRESS
]
targets.extend(host_port(ip, port) for port in ports)
return targets
+3 -7
View File
@@ -11,10 +11,6 @@ class PassivePortsConfig(StageConfig):
title="Look up indexed ports",
description="Read ports indexed for each address by internet-wide scanners.",
)
max_addresses: int = Field(
default=1024,
ge=1,
le=65536,
title="Address budget",
description="Stop after this many addresses.",
)
MAX_ADDRESSES = 1024
+2 -3
View File
@@ -13,7 +13,7 @@ from shared.services import ip_inventory, port_inventory
from shared.services.port_inventory import ServiceObservation
from shared.services.scope_filter import ip_excluded
from stages.base import ALL_TARGETS, Stage, StageResult
from stages.passive_ports.config import PassivePortsConfig
from stages.passive_ports.config import MAX_ADDRESSES, PassivePortsConfig
from tools.naabu.client import NaabuClient, NaabuError, NaabuOptions
logger = get_logger(__name__)
@@ -36,7 +36,6 @@ class PassivePortsStage(Stage):
def run(self) -> StageResult:
self._check_abort()
cfg = self.cfg
ip_inventory.ensure(
self.session,
scan_id=self.ctx.scan_id,
@@ -44,7 +43,7 @@ class PassivePortsStage(Stage):
project_id=self.ctx.project_id,
)
self.publish_results(SurfaceDimension.IPS.value)
addresses = self._candidates(cfg.max_addresses)
addresses = self._candidates(MAX_ADDRESSES)
if not addresses:
return StageResult(counts={"known_ports": 0, "addresses": 0})
+17 -35
View File
@@ -8,7 +8,7 @@ from shared.definitions.ports import (
PortProfile,
ScanPolicy,
)
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig, advanced
_PROFILE_LABELS = {spec.key: spec.label for spec in PORT_PROFILES}
_SCAN_TYPES = {"connect": "Connect", "syn": "SYN"}
@@ -32,59 +32,41 @@ class PortScanConfig(StageConfig):
title="Custom ports",
description="Used only when the port set is Custom. A list or range like 80,443,8000-8100.",
)
exclude_ports: str = Field(
default="",
exclude_ports: str = advanced(
"",
max_length=2000,
title="Exclude ports",
description="Ports excluded from every scan, as a list or range.",
)
scan_type: str = Field(
default="connect",
scan_type: str = advanced(
"connect",
title="Scan type",
description="Connect completes the TCP handshake. SYN needs raw sockets.",
description="Connect completes the TCP handshake. SYN needs raw sockets on the worker.",
json_schema_extra={"options": list(_SCAN_TYPES), "option_labels": _SCAN_TYPES},
)
rate: int = rate(1000, tool="naabu", title="Packet rate (pps)")
threads: int = threads(
100,
title="Concurrency",
description="Sockets in flight.",
)
timeout: int = timeout(3, title="Timeout (s)")
retries: int = Field(
default=1,
ge=0,
le=5,
title="Retries",
description="Extra attempts per port.",
)
cdn_policy: ScanPolicy = Field(
default=ScanPolicy.WEB,
cdn_policy: ScanPolicy = advanced(
ScanPolicy.WEB,
title="CDN-fronted addresses",
description="Port set for addresses attributed to a CDN or WAF.",
json_schema_extra={"option_labels": SCAN_POLICY_LABELS},
)
scan_cloud: bool = Field(
default=True,
scan_cloud: bool = advanced(
True,
title="Scan cloud addresses in full",
description="Scan addresses attributed to a cloud provider with the full port set.",
)
skip_private: bool = Field(
default=True,
skip_private: bool = advanced(
True,
title="Skip private addresses",
description="Skip loopback, link-local and RFC1918 addresses. An address or netblock named as the target is scanned.",
)
port_threshold: int = Field(
default=500,
ge=0,
le=65535,
title="Open-port threshold",
description="Discard a host reporting more open ports than this. 0 disables.",
)
max_addresses: int = Field(
default=8192,
max_addresses: int = advanced(
8192,
ge=1,
le=100000,
title="Address budget",
description="Stop after this many addresses.",
)
PORT_THRESHOLD = 500
+8 -6
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass
from sqlalchemy import select
from shared.definitions.intensity import TransportTool
from shared.definitions.ports import (
CDN_EDGE_PORTS,
PortProfile,
@@ -22,7 +23,7 @@ from shared.services.port_inventory import ServiceObservation
from shared.services.scope_filter import ip_excluded
from shared.utils.net import is_registry_routable
from stages.base import ALL_TARGETS, Stage, StageResult
from stages.port_scan.config import PortScanConfig
from stages.port_scan.config import PORT_THRESHOLD, PortScanConfig
from tools.naabu.client import NaabuClient, NaabuError, NaabuOptions, port_args
logger = get_logger(__name__)
@@ -51,6 +52,7 @@ class PortScanStage(Stage):
produces = frozenset({AssetKind.PORTS.value})
applies_to = ALL_TARGETS
tools = ("naabu",)
transport_tool = TransportTool.NAABU.value
config_model = PortScanConfig
def run(self) -> StageResult:
@@ -80,12 +82,12 @@ class PortScanStage(Stage):
try:
client = NaabuClient(
options=NaabuOptions(
rate=cfg.rate,
concurrency=cfg.threads,
timeout=cfg.timeout,
retries=cfg.retries,
rate=self.transport.rate or 1,
concurrency=self.transport.threads,
timeout=self.transport.timeout,
retries=self.transport.retries,
scan_type=cfg.scan_type,
port_threshold=cfg.port_threshold,
port_threshold=PORT_THRESHOLD,
exclude_ports=cfg.exclude_ports,
proxy_url=self.net_options().proxy_url,
extra_args=self.ctx.resolved.tool_args("naabu"),
+22 -7
View File
@@ -9,7 +9,8 @@ from functools import lru_cache
from pathlib import Path
import stages as stages_pkg
from shared.enums.scan import PHASE_ORDER, AssetKind, StageGroup, StageRole
from shared.definitions.intensity import PROFILES, RATE_TOOLS
from shared.enums.scan import PHASE_ORDER, AssetKind, Intensity, StageGroup, StageRole
from stages.base import Stage
from stages.config import StageConfig
@@ -35,6 +36,10 @@ class StageSpec:
produces: frozenset[str]
group: str
role: str
transport_tool: str | None
rate_weight: float
thread_weight: float
transport_timeout: int | None
stage_cls: type[Stage]
config_model: type[StageConfig]
@@ -46,6 +51,12 @@ class StageSpec:
def schema(self) -> dict:
return self.config_model.model_json_schema()
def transport(self, intensity: str, **scaling):
"""The stage's transport under this intensity, or None when it runs no tool."""
if self.transport_tool is None:
return None
return self.stage_cls.default_transport(intensity, **scaling)
class StageRegistrationError(RuntimeError):
"""A stage module declares an invalid or duplicate stage."""
@@ -111,6 +122,10 @@ def _spec(stage_cls: type[Stage], level: int) -> StageSpec:
if unknown:
msg = f"{stage_cls.name}: unknown asset kind {sorted(unknown)[0]!r}."
raise StageRegistrationError(msg)
tool = stage_cls.transport_tool
if tool is not None and tool not in PROFILES[Intensity.NORMAL.value]:
msg = f"{stage_cls.name}: no transport profile for {tool!r}."
raise StageRegistrationError(msg)
return StageSpec(
name=stage_cls.name,
title=getattr(stage_cls, "title", None)
@@ -132,6 +147,10 @@ def _spec(stage_cls: type[Stage], level: int) -> StageSpec:
produces=frozenset(stage_cls.produces),
group=stage_cls.group,
role=stage_cls.role,
transport_tool=stage_cls.transport_tool,
rate_weight=stage_cls.rate_weight,
thread_weight=stage_cls.thread_weight,
transport_timeout=stage_cls.transport_timeout,
stage_cls=stage_cls,
config_model=stage_cls.config_model,
)
@@ -267,12 +286,8 @@ def phases() -> list[tuple[str, list[StageSpec]]]:
def rate_tools() -> tuple[str, ...]:
tools: list[str] = []
for spec in stages():
for tool in sorted(spec.config_model.rate_tools()):
if tool not in tools:
tools.append(tool)
return tuple(tools)
used = {spec.transport_tool for spec in stages()}
return tuple(tool for tool in RATE_TOOLS if tool in used)
__all__ = [
+1 -3
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, threads, timeout
from stages.config import StageConfig
class ReverseDnsConfig(StageConfig):
@@ -11,5 +11,3 @@ class ReverseDnsConfig(StageConfig):
title="Reverse DNS",
description="Resolve PTR records for every discovered IP.",
)
dns_threads: int = threads(30, title="DNS threads")
dns_timeout: int = timeout(5, title="DNS timeout (s)")
+10 -7
View File
@@ -2,9 +2,11 @@ from __future__ import annotations
from sqlalchemy import select
from shared.definitions.intensity import TransportTool
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
from shared.logging import get_logger
from shared.models.ip_address import IpAddress
from shared.services.scope_filter import ip_excluded
from stages.base import ALL_TARGETS, Stage, StageResult
from stages.reverse_dns.config import ReverseDnsConfig
from tools.dnsx.client import DnsxClient, DnsxError
@@ -27,12 +29,12 @@ class ReverseDnsStage(Stage):
produces = frozenset({AssetKind.HOSTS.value})
applies_to = ALL_TARGETS
tools = ("dnsx",)
transport_tool = TransportTool.DNSX.value
touches_target = False
config_model = ReverseDnsConfig
def run(self) -> StageResult:
self._check_abort()
cfg = self.cfg
rows = list(
self.session.execute(
select(IpAddress).where(IpAddress.scan_id == self.ctx.scan_id)
@@ -40,12 +42,15 @@ class ReverseDnsStage(Stage):
.scalars()
.all()
)
excluded = self.ctx.resolved.excluded_ips or []
if excluded:
rows = [row for row in rows if not ip_excluded(row.ip, excluded)]
if not rows:
return StageResult(counts={"ptr": 0})
by_ip = {row.ip: row for row in rows}
ips = list(by_ip.keys())[:_MAX_PTR]
ptr_map, note = self._lookup_ptr(ips, cfg)
ptr_map, note = self._lookup_ptr(ips)
resolved = 0
for ip, names in ptr_map.items():
@@ -62,15 +67,13 @@ class ReverseDnsStage(Stage):
partial=bool(note),
)
def _lookup_ptr(
self, ips: list[str], cfg: ReverseDnsConfig
) -> tuple[dict[str, list], str | None]:
def _lookup_ptr(self, ips: list[str]) -> tuple[dict[str, list], str | None]:
if not ips:
return {}, None
try:
client = DnsxClient(
timeout=max(120, cfg.dns_timeout),
threads=cfg.dns_threads,
timeout=max(120, self.transport.timeout),
threads=self.transport.threads,
recorder=self.ctx.recorder,
extra_args=self.ctx.resolved.tool_args("dnsx"),
)
+1 -6
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig
class ScreenshotConfig(StageConfig):
@@ -11,8 +11,3 @@ class ScreenshotConfig(StageConfig):
title="Capture screenshots",
description="Render every live HTTP service to an image.",
)
threads: int = threads(40, title="Threads")
rate: int = rate(150, tool="httpx", title="Renders/s")
timeout: int = timeout(
10, title="Timeout (s)", description="Per-page render budget."
)
+6 -4
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from sqlalchemy import select, update
from shared.definitions.intensity import TransportTool
from shared.definitions.surface import SurfaceDimension
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
from shared.enums.target import TargetType
@@ -42,11 +43,12 @@ class ScreenshotStage(Stage):
role = StageRole.CAPABILITY.value
consumes = frozenset({AssetKind.HTTP_ASSETS.value})
tools = ("httpx",)
transport_tool = TransportTool.HTTPX.value
thread_weight = 4 / 15
config_model = ScreenshotConfig
def run(self) -> StageResult:
self._check_abort()
cfg = self.cfg
net = self.net_options()
live = self.session.execute(
select(HttpAsset.id, HttpAsset.url).where(
@@ -60,9 +62,9 @@ class ScreenshotStage(Stage):
store_dir = str(Path(_MEDIA_ROOT) / str(self.ctx.scan_id))
try:
client = HttpxClient(
rate_limit=cfg.rate,
threads=cfg.threads,
timeout=cfg.timeout,
rate_limit=self.transport.rate,
threads=self.transport.threads,
timeout=self.transport.timeout,
proxy_url=net.proxy_url,
headers=net.headers,
probe_scheme=net.probe_scheme,
+1 -6
View File
@@ -12,7 +12,7 @@ _MAX_HOSTS = {"smart": 4096, "full": 65536}
class SeedResolutionConfig(StageConfig):
enabled: bool = Field(
default=True,
title="Enabled",
title="Seed resolution",
description="Expand an IP, netblock, ASN or URL seed into individual addresses.",
)
asn_scan_mode: Literal["smart", "full"] = Field(
@@ -20,11 +20,6 @@ class SeedResolutionConfig(StageConfig):
title="ASN / CIDR expansion",
description="Smart samples large netblocks. Full enumerates every host.",
)
cidr_skip_rfc1918: bool = Field(
default=True,
title="Skip private ranges in ASN prefixes",
description="Drop RFC1918 addresses when expanding an ASN's prefixes. A netblock target is expanded in full.",
)
@property
def max_expansion_hosts(self) -> int:
+4 -2
View File
@@ -9,6 +9,7 @@ from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
from shared.enums.target import TargetType
from shared.logging import get_logger
from shared.models.ip_address import IpAddress
from shared.services.scope_filter import ip_excluded
from shared.utils.cidr import expand_network, parse_network
from shared.utils.datetime import utc_now
from shared.utils.validation import normalize_domain
@@ -166,7 +167,7 @@ class SeedResolutionStage(Stage):
if net is None:
continue
ips, was_truncated = expand_network(
prefix, max_hosts=per_prefix, skip_private=cfg.cidr_skip_rfc1918
prefix, max_hosts=per_prefix, skip_private=True
)
truncated = truncated or was_truncated
records.extend(
@@ -202,9 +203,10 @@ class SeedResolutionStage(Stage):
)
now = utc_now()
seen: set[str] = set()
excluded = self.ctx.resolved.excluded_ips or []
for rec in records:
ip = rec["ip"]
if ip in seen:
if ip in seen or (excluded and ip_excluded(ip, excluded)):
continue
seen.add(ip)
self.session.add(
+3 -10
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, threads, timeout
from stages.config import StageConfig, advanced
class ServiceFingerprintConfig(StageConfig):
@@ -11,17 +11,10 @@ class ServiceFingerprintConfig(StageConfig):
title="Fingerprint services",
description="Identify the software behind every non-web port from its service banner.",
)
threads: int = threads(32, title="Concurrency")
timeout: int = timeout(4, title="Timeout (s)")
max_services: int = Field(
default=2000,
max_services: int = advanced(
2000,
ge=1,
le=50000,
title="Service budget",
description="Stop after this many ports.",
)
include_unknown: bool = Field(
default=True,
title="Include unrecognised ports",
description="Also probe ports with no well-known service.",
)
+4 -5
View File
@@ -2,11 +2,11 @@ from __future__ import annotations
from sqlalchemy import select
from shared.definitions.intensity import TransportTool
from shared.definitions.ports import (
CDN_EDGE_PORTS,
PortSource,
ScanPolicy,
ServiceClass,
likely_tls,
)
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
@@ -36,6 +36,7 @@ class ServiceFingerprintStage(Stage):
consumes = frozenset({AssetKind.PORTS.value})
applies_to = ALL_TARGETS
tools = ()
transport_tool = TransportTool.BANNER.value
config_model = ServiceFingerprintConfig
def run(self) -> StageResult:
@@ -46,8 +47,8 @@ class ServiceFingerprintStage(Stage):
return StageResult(counts={"fingerprinted": 0, "probed": 0})
client = BannerClient(
timeout=float(cfg.timeout),
concurrency=cfg.threads,
timeout=float(self.transport.timeout),
concurrency=self.transport.threads,
proxy_url=self.net_options().proxy_url,
)
if client.proxy_warning:
@@ -112,8 +113,6 @@ class ServiceFingerprintStage(Stage):
Port.protocol == "tcp",
)
)
if not cfg.include_unknown:
query = query.where(Port.service_class != ServiceClass.OTHER.value)
rows = self.session.execute(
query.order_by(Port.ip, Port.number).limit(cfg.max_services)
).all()
+1 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, timeout
from stages.config import StageConfig
class SessionCheckConfig(StageConfig):
@@ -11,4 +11,3 @@ class SessionCheckConfig(StageConfig):
title="Check the session before scanning",
description="Request the target once with the scan context's credentials and once without. Runs only when the context carries credentials.",
)
timeout: int = timeout(15, title="Timeout (s)")
+8 -4
View File
@@ -10,6 +10,8 @@ from shared.logging import get_logger
from stages.base import ALL_TARGETS, Stage, StageResult
from stages.session_check.config import SessionCheckConfig
REQUEST_TIMEOUT = 15
logger = get_logger(__name__)
_UNAUTHORISED = (401, 403)
@@ -73,16 +75,18 @@ class SessionCheckStage(Stage):
value = self.ctx.target_value.strip().rstrip("/")
if not value:
return []
scheme = self.net_options().probe_scheme
if self.ctx.target_type == TargetType.URL.value:
return [value]
return [value] if scheme is None or value.startswith(f"{scheme}://") else []
if self.ctx.target_type != TargetType.DOMAIN.value:
return []
return [f"https://{value}", f"http://{value}"]
schemes = [scheme] if scheme else ["https", "http"]
return [f"{s}://{value}" for s in schemes]
def _client(self) -> httpx.Client:
return httpx.Client(
timeout=self.cfg.timeout,
follow_redirects=True,
timeout=REQUEST_TIMEOUT,
follow_redirects=self.follow_redirects(True),
verify=False, # noqa: S501
proxy=self.ctx.resolved.proxy_url or None,
)
+13 -43
View File
@@ -4,7 +4,7 @@ from pydantic import Field
from shared.definitions.wordlists import WordlistKind
from shared.enums.scan import Intensity
from stages.config import StageConfig, threads, timeout, wordlist
from stages.config import StageConfig, wordlist
from stages.subdomain.providers import PASSIVE_PROVIDERS
PASSIVE_TOOLS: tuple[str, ...] = tuple(
@@ -24,10 +24,15 @@ _TOOL_TIMEOUTS = {
}
def tool_timeout(intensity: str) -> int:
"""Seconds a passive source may run under this intensity."""
return _TOOL_TIMEOUTS.get(intensity, _TOOL_TIMEOUTS[Intensity.NORMAL.value])
class SubdomainConfig(StageConfig):
enabled: bool = Field(
default=True,
title="Enabled",
title="Subdomain discovery",
description="Enumerate subdomains from passive sources, certificates, wordlists and permutations.",
)
passive_tools: list[str] = Field(
@@ -68,49 +73,14 @@ class SubdomainConfig(StageConfig):
title="Permute discovered names",
description="Resolve variants of discovered names, such as api-dev and api2.",
)
permutation_seeds: int = Field(
default=250,
ge=1,
le=10_000,
title="Names to permute",
description="Discovered names to build variants from.",
)
permutation_limit: int = Field(
default=20_000,
ge=100,
le=500_000,
title="Variants to resolve",
description="Cap on generated variants.",
)
dns_threads: int = threads(30, title="Resolver threads")
dns_batch_size: int = Field(
default=1000,
ge=100,
le=20000,
title="Resolver batch size",
description="Names sent to the resolver per invocation.",
)
dns_batch_concurrency: int = Field(
default=1,
ge=1,
le=8,
title="Resolver batches in parallel",
description="Resolver invocations in flight at once. Above 1 only with dedicated resolvers.",
)
dns_idle_timeout: int = timeout(
90,
title="Resolver stall timeout",
description="Abandon a resolver batch after this many seconds with no answer.",
)
dns_retry_silent: bool = Field(
default=True,
title="Re-query unanswered names",
description="Ask the resolver a second time for names that returned nothing.",
)
@property
def enabled_sources(self) -> list[str]:
return [tool for tool in self.passive_tools if tool in PASSIVE_PROVIDERS]
def tool_timeout(self, intensity: str) -> int:
return _TOOL_TIMEOUTS.get(intensity, _TOOL_TIMEOUTS[Intensity.NORMAL.value])
PERMUTATION_SEEDS = 250
PERMUTATION_LIMIT = 20_000
DNS_BATCH_SIZE = 1000
DNS_BATCH_CONCURRENCY = 1
DNS_IDLE_TIMEOUT = 90
+48 -44
View File
@@ -15,6 +15,7 @@ from pathlib import Path
from sqlalchemy import bindparam, cast, delete, not_, or_, update
from sqlalchemy.dialects.postgresql import JSONB, insert
from shared.definitions.intensity import TransportTool
from shared.definitions.rescan import SEED_SOURCES
from shared.definitions.surface import SurfaceDimension
from shared.enums.activity import ActivityEvent, ActivityLevel
@@ -30,7 +31,16 @@ from shared.services.scope_filter import matches_any
from shared.services.wordlists import WordlistError, read_words
from shared.utils.datetime import utc_now
from stages.base import DOMAIN_TARGETS, Stage, StageResult
from stages.subdomain.config import PASSIVE_TOOLS, SubdomainConfig
from stages.subdomain.config import (
DNS_BATCH_CONCURRENCY,
DNS_BATCH_SIZE,
DNS_IDLE_TIMEOUT,
PASSIVE_TOOLS,
PERMUTATION_LIMIT,
PERMUTATION_SEEDS,
SubdomainConfig,
tool_timeout,
)
from stages.subdomain.parser import in_scope, merge_and_filter
from stages.subdomain.providers import (
PASSIVE_PROVIDERS,
@@ -136,6 +146,7 @@ class SubdomainStage(Stage):
produces = frozenset({AssetKind.HOSTS.value, AssetKind.ADDRESSES.value})
applies_to = DOMAIN_TARGETS
tools = PASSIVE_TOOLS
transport_tool = TransportTool.DNSX.value
api_keys = tuple(p.value for p in _PREFETCH_KEYS)
touches_target = False
config_model = SubdomainConfig
@@ -145,6 +156,7 @@ class SubdomainStage(Stage):
return cfg.enabled and bool(
cfg.enabled_sources
or cfg.tls_discovery
or cfg.zone_transfer
or cfg.bruteforce
or cfg.permutations
)
@@ -162,8 +174,8 @@ class SubdomainStage(Stage):
api_keys = self._prefetch_keys()
pctx = ProviderContext(
domain=domain,
timeout=cfg.tool_timeout(resolved.intensity),
threads=cfg.dns_threads,
timeout=tool_timeout(resolved.intensity),
threads=self.transport.threads,
proxy_url=resolved.proxy_url,
api_keys=api_keys,
recorder=self.ctx.recorder,
@@ -182,7 +194,7 @@ class SubdomainStage(Stage):
self._check_abort()
merged = merge_and_filter(results, domain, resolved.included_subdomains)
wildcard_ips = self._wildcard_ips(domain, cfg)
wildcard_ips = self._wildcard_ips(domain)
extra = self._expand(
domain, cfg, sorted(merged, key=_seed_rank), wildcard_ips, activity
)
@@ -205,7 +217,7 @@ class SubdomainStage(Stage):
self._write_names(merged)
to_resolve = [n for n in merged if n not in excluded]
state = self._resolve(to_resolve, cfg, wildcard_ips)
state = self._resolve(to_resolve, wildcard_ips)
active, ips_seen = self._persist(merged, state.records, wildcard_ips, excluded)
return StageResult(
@@ -233,11 +245,11 @@ class SubdomainStage(Stage):
passive = self.ctx.resolved.intensity == Intensity.PASSIVE.value
out: list[ProviderResult] = []
if cfg.zone_transfer:
out.append(self._zone_transfer(domain, cfg, passive=passive))
out.append(self._zone_transfer(domain, passive=passive))
if cfg.bruteforce:
out.append(self._bruteforce(domain, cfg, wildcard_ips, passive=passive))
if cfg.permutations:
out.append(self._permute(cfg, seeds, wildcard_ips, passive=passive))
out.append(self._permute(seeds, wildcard_ips, passive=passive))
for result in out:
self._check_abort()
self._log_provider(activity, result)
@@ -270,7 +282,6 @@ class SubdomainStage(Stage):
def _zone_transfer(
self,
domain: str,
cfg: SubdomainConfig,
*,
passive: bool,
) -> ProviderResult:
@@ -279,7 +290,7 @@ class SubdomainStage(Stage):
return self._skipped(
source, "a passive scan does not query the target's nameservers"
)
client = self._client(cfg)
client = self._client()
if client is None:
return self._skipped(source, "dnsx is not installed on this instance")
@@ -335,7 +346,7 @@ class SubdomainStage(Stage):
return self._skipped(
source, "a passive scan does not query the target's nameservers"
)
client = self._client(cfg)
client = self._client()
if client is None:
return self._skipped(source, "dnsx is not installed on this instance")
@@ -381,7 +392,6 @@ class SubdomainStage(Stage):
def _permute(
self,
cfg: SubdomainConfig,
seeds: list[str],
wildcard_ips: set[str],
*,
@@ -398,7 +408,7 @@ class SubdomainStage(Stage):
)
try:
client = AlterxClient(
limit=cfg.permutation_limit,
limit=PERMUTATION_LIMIT,
recorder=self.ctx.recorder,
extra_args=self.ctx.resolved.tool_args("alterx"),
)
@@ -406,11 +416,11 @@ class SubdomainStage(Stage):
return self._skipped(source, "alterx is not installed on this instance")
start = time.monotonic()
candidates = client.permute(seeds[: cfg.permutation_seeds])
candidates = client.permute(seeds[:PERMUTATION_SEEDS])
if not candidates:
return ProviderResult(source=source, duration_seconds=0.0)
self.emit_progress(f"resolving {len(candidates):,} name variants")
client = self._client(cfg)
client = self._client()
if client is None:
return self._skipped(source, "dnsx is not installed on this instance")
found: set[str] = set()
@@ -438,9 +448,9 @@ class SubdomainStage(Stage):
duration_seconds=round(time.monotonic() - start, 2),
)
def _wildcard_ips(self, domain: str, cfg: SubdomainConfig) -> set[str]:
def _wildcard_ips(self, domain: str) -> set[str]:
probe = f"{uuid.uuid4().hex[:12]}.{domain}"
info = self._resolve([probe], cfg).records.get(probe)
info = self._resolve([probe]).records.get(probe)
return set(info["ips"]) if info and info.get("ips") else set()
@staticmethod
@@ -530,11 +540,13 @@ class SubdomainStage(Stage):
self.session.commit()
self.emit_progress(message, source=source)
def _client(self, cfg: SubdomainConfig) -> DnsxClient | None:
threads = min(max(cfg.dns_threads, _MIN_RESOLVE_THREADS), _MAX_RESOLVE_THREADS)
def _client(self) -> DnsxClient | None:
threads = min(
max(self.transport.threads, _MIN_RESOLVE_THREADS), _MAX_RESOLVE_THREADS
)
try:
return DnsxClient(
timeout=max(120, cfg.tool_timeout(self.ctx.resolved.intensity)),
timeout=max(120, tool_timeout(self.ctx.resolved.intensity)),
threads=threads,
recorder=self.ctx.recorder,
extra_args=self.ctx.resolved.tool_args("dnsx"),
@@ -558,14 +570,14 @@ class SubdomainStage(Stage):
}
def _run_batch(
self, client: DnsxClient, names: list[str], cfg: SubdomainConfig
self, client: DnsxClient, names: list[str]
) -> tuple[dict[str, dict], bool]:
"""Resolve one batch."""
out: dict[str, dict] = {}
with client.stream_query(
names,
record_types=["a", "aaaa", "cname"],
idle_timeout=cfg.dns_idle_timeout,
idle_timeout=DNS_IDLE_TIMEOUT,
) as stream:
for rec in stream.records:
parsed = self._record(rec)
@@ -577,28 +589,27 @@ class SubdomainStage(Stage):
def _resolve(
self,
names: list[str],
cfg: SubdomainConfig,
wildcard_ips: set[str] | None = None,
) -> _Resolution:
"""wildcard_ips is None for the wildcard probe itself, whose name is not stored."""
state = _Resolution(submitted=len(names))
if not names:
return state
client = self._client(cfg)
client = self._client()
if client is None:
state.unavailable = True
return state
shuffled = list(names)
random.Random(_SHUFFLE_SEED).shuffle(shuffled) # noqa: S311
size = max(1, cfg.dns_batch_size)
size = DNS_BATCH_SIZE
batches = [
_Batch(names=shuffled[i : i + size]) for i in range(0, len(shuffled), size)
]
state.batches = len(batches)
for done, (batch, (records, stalled)) in enumerate(
self._resolve_batches(client, batches, cfg), start=1
self._resolve_batches(client, batches), start=1
):
state.records.update(records)
batch.answered = len(records)
@@ -618,26 +629,24 @@ class SubdomainStage(Stage):
note += ", stopped on silence"
self.emit_progress(note)
self._retry_degraded(client, batches, state, cfg)
self._retry_silent(client, names, state, cfg, wildcard_ips)
self._retry_degraded(client, batches, state)
self._retry_silent(client, names, state, wildcard_ips)
return state
def _resolve_batches(
self, client: DnsxClient, batches: list[_Batch], cfg: SubdomainConfig
):
def _resolve_batches(self, client: DnsxClient, batches: list[_Batch]):
"""Batches are independent."""
workers = min(max(1, cfg.dns_batch_concurrency), len(batches))
workers = min(DNS_BATCH_CONCURRENCY, len(batches))
if workers == 1:
for batch in batches:
started = time.monotonic()
answered = self._run_batch(client, batch.names, cfg)
answered = self._run_batch(client, batch.names)
batch.seconds = time.monotonic() - started
yield batch, answered
return
with ThreadPoolExecutor(max_workers=workers) as pool:
started = time.monotonic()
futures = {
pool.submit(self._run_batch, client, batch.names, cfg): batch
pool.submit(self._run_batch, client, batch.names): batch
for batch in batches
}
for future in as_completed(futures):
@@ -650,7 +659,6 @@ class SubdomainStage(Stage):
client: DnsxClient,
batches: list[_Batch],
state: _Resolution,
cfg: SubdomainConfig,
) -> None:
"""Resolve again any batch far below its peers."""
healthy = [b.rate for b in batches if not b.stalled]
@@ -669,7 +677,7 @@ class SubdomainStage(Stage):
]
retries = [(batch, pending) for batch, pending in retries if pending]
state.retried += len(retries)
for batch, (records, stalled) in self._retry_batches(client, retries, cfg):
for batch, (records, stalled) in self._retry_batches(client, retries):
batch.stalled = stalled
state.records.update(records)
batch.answered += len(records)
@@ -683,13 +691,10 @@ class SubdomainStage(Stage):
client: DnsxClient,
names: list[str],
state: _Resolution,
cfg: SubdomainConfig,
wildcard_ips: set[str] | None,
) -> None:
"""dnsx says nothing for a name it dropped and for a name that does not exist."""
if not cfg.dns_retry_silent:
return
size = max(1, cfg.dns_batch_size)
size = DNS_BATCH_SIZE
for _ in range(_SILENCE_PASSES):
pending = [n for n in names if n not in state.records]
if not pending:
@@ -699,7 +704,7 @@ class SubdomainStage(Stage):
for start in range(0, len(pending), size):
self._check_abort()
records, _stalled = self._run_batch(
client, pending[start : start + size], cfg
client, pending[start : start + size]
)
state.records.update(records)
found += len(records)
@@ -714,16 +719,15 @@ class SubdomainStage(Stage):
self,
client: DnsxClient,
retries: list[tuple[_Batch, list[str]]],
cfg: SubdomainConfig,
):
workers = min(max(1, cfg.dns_batch_concurrency), len(retries) or 1)
workers = min(DNS_BATCH_CONCURRENCY, len(retries) or 1)
if workers == 1:
for batch, pending in retries:
yield batch, self._run_batch(client, pending, cfg)
yield batch, self._run_batch(client, pending)
return
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {
pool.submit(self._run_batch, client, pending, cfg): batch
pool.submit(self._run_batch, client, pending): batch
for batch, pending in retries
}
for future in as_completed(futures):
+2 -4
View File
@@ -2,14 +2,12 @@ from __future__ import annotations
from pydantic import Field
from stages.config import StageConfig, threads, timeout
from stages.config import StageConfig
class TargetEnrichmentConfig(StageConfig):
enabled: bool = Field(
default=True,
title="Enabled",
title="Target enrichment",
description="Resolve the target and attach DNS, WHOIS and BGP context.",
)
dns_threads: int = threads(30, title="DNS threads")
dns_timeout: int = timeout(5, title="DNS timeout (s)")
+6 -5
View File
@@ -4,6 +4,7 @@ from datetime import datetime, timedelta
from sqlalchemy import select
from shared.definitions.intensity import TransportTool
from shared.enums.scan import Phase, StageGroup, StageRole
from shared.enums.target import TargetType
from shared.enums.task_status import TaskStatus
@@ -39,19 +40,19 @@ class TargetEnrichmentStage(Stage):
group = StageGroup.HOSTS.value
role = StageRole.SUPPORT.value
tools = ("dnsx", "whois")
transport_tool = TransportTool.DNSX.value
touches_target = False
config_model = TargetEnrichmentConfig
_notes: list[str]
def run(self) -> StageResult:
self._check_abort()
cfg = self.cfg
target = self.session.get(Target, self.ctx.target_id)
if target is None:
return StageResult(counts={})
self._notes = []
dns_records = self._ensure_dns(target, cfg)
dns_records = self._ensure_dns(target)
whois_present = self._ensure_whois(target)
bgp_present = self._read_bgp()
@@ -70,7 +71,7 @@ class TargetEnrichmentStage(Stage):
partial=bool(self._notes),
)
def _ensure_dns(self, target: Target, cfg: TargetEnrichmentConfig) -> int:
def _ensure_dns(self, target: Target) -> int:
if self.ctx.target_type not in _DNS_TYPES:
return 0
lookup = (
@@ -82,8 +83,8 @@ class TargetEnrichmentStage(Stage):
host = normalize_domain(self.ctx.target_value)
try:
lookup = DnsxService(
timeout=max(120, cfg.dns_timeout),
threads=cfg.dns_threads,
timeout=max(120, self.transport.timeout),
threads=self.transport.threads,
recorder=self.ctx.recorder,
extra_args=self.ctx.resolved.tool_args("dnsx"),
).lookup_and_store(self.session, target.id, host)
+24 -53
View File
@@ -13,7 +13,7 @@ from shared.definitions.endpoints import (
CrawlScope,
EndpointSource,
)
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig, advanced
DEFAULT_PROVIDERS: list[str] = [
EndpointSource.RESPONSE_MINING.value,
@@ -44,9 +44,6 @@ class UrlDiscoveryConfig(StageConfig):
description="Sources URLs are collected from. Response mining reads stored response bodies and sends no request.",
json_schema_extra={"options": list(_PROVIDER_LABELS)},
)
threads: int = threads(50, title="Threads")
timeout: int = timeout(15, title="Timeout (s)")
rate: int = rate(150, tool="katana", title="Requests/s")
crawl_depth: int = Field(
default=3,
ge=1,
@@ -54,17 +51,6 @@ class UrlDiscoveryConfig(StageConfig):
title="Crawl depth",
description="Link depth followed from each site root.",
)
crawl_scope: str = Field(
default=CrawlScope.RDN.value,
title="Crawl scope",
description="Which hosts the crawler may follow links to.",
json_schema_extra={"options": list(CRAWL_SCOPES)},
)
crawl_javascript: bool = Field(
default=True,
title="Parse JavaScript",
description="Read URLs out of the JavaScript each page loads.",
)
headless: bool = Field(
default=False,
title="Use a browser",
@@ -77,68 +63,47 @@ class UrlDiscoveryConfig(StageConfig):
title="Crawl budget (min)",
description="Stop crawling after this long. 0 means no limit.",
)
max_urls: int = Field(
default=50000,
ge=100,
le=200000,
title="URLs per source",
description="Cap the URLs any one source may contribute.",
crawl_scope: str = advanced(
CrawlScope.RDN.value,
title="Crawl scope",
description="Which hosts the crawler may follow links to.",
json_schema_extra={"options": list(CRAWL_SCOPES)},
)
max_known_file_hosts: int = Field(
default=200,
ge=1,
le=5000,
title="Hosts for robots and sitemaps",
description="Cap the hosts whose robots.txt and sitemap are fetched.",
)
max_source_maps: int = Field(
default=200,
ge=1,
le=5_000,
title="Bundles to ask for a map",
description="JavaScript bundles asked for the .map beside them.",
)
max_archive_domains: int = Field(
default=10,
ge=1,
le=200,
title="Domains to query archives for",
description="Registrable domains queried in public archives.",
)
max_hosts: int = Field(
default=500,
max_hosts: int = advanced(
500,
ge=1,
le=10000,
title="Hosts to crawl",
description="Cap the live web assets handed to the crawler.",
)
drop_noise: bool = Field(
default=True,
drop_noise: bool = advanced(
True,
title="Drop noise",
description="Static files, crawler artifacts, platform noise and URLs past the family and sibling caps are not stored. Every drop is counted.",
)
static_extensions: list[str] = Field(
static_extensions: list[str] = advanced(
None,
default_factory=lambda: list(NOISE_STATIC_EXTENSIONS),
max_length=200,
title="Static extensions",
description="File extensions dropped as static.",
)
ignored_params: list[str] = Field(
ignored_params: list[str] = advanced(
None,
default_factory=lambda: list(NOISE_IGNORED_PARAMS),
max_length=400,
title="Ignored parameters",
description="Query parameters removed from every URL. Tracking and cache-busting names by default.",
)
keep_per_family: int = Field(
default=NOISE_KEEP_PER_FAMILY,
keep_per_family: int = advanced(
NOISE_KEEP_PER_FAMILY,
ge=1,
le=MAX_NOISE_KEEP_PER_FAMILY,
title="URLs kept per family",
description="URLs stored for one path shape and parameter set. The rest are dropped.",
)
sibling_cap: int = Field(
default=NOISE_SIBLING_CAP,
sibling_cap: int = advanced(
NOISE_SIBLING_CAP,
ge=1,
le=MAX_NOISE_SIBLING_CAP,
title="Sibling cap",
@@ -163,3 +128,9 @@ class UrlDiscoveryConfig(StageConfig):
@classmethod
def _known_scope(cls, value: str) -> str:
return value if value in CRAWL_SCOPES else CrawlScope.RDN.value
MAX_URLS = 50_000
MAX_KNOWN_FILE_HOSTS = 200
MAX_SOURCE_MAPS = 200
MAX_ARCHIVE_DOMAINS = 10
+5 -5
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from shared.definitions.endpoints import EndpointSource
from shared.services.endpoint_inventory import EndpointObservation
from stages.url_discovery.config import MAX_ARCHIVE_DOMAINS, MAX_URLS
from stages.url_discovery.providers.base import ProviderResult, UrlProvider
from tools.urlfinder.client import UrlfinderClient, UrlfinderError
@@ -15,13 +16,12 @@ class ArchiveProvider(UrlProvider):
touches_target = False
def discover(self, result: ProviderResult) -> None:
cfg = self.ctx.cfg
domains = self.ctx.apex_domains[: cfg.max_archive_domains]
domains = self.ctx.apex_domains[:MAX_ARCHIVE_DOMAINS]
if not domains:
return
try:
client = UrlfinderClient(
timeout=cfg.timeout,
timeout=self.ctx.transport.timeout,
proxy_url=self.ctx.net.proxy_url,
recorder=self.ctx.recorder,
extra_args=self.extra_args,
@@ -32,7 +32,7 @@ class ArchiveProvider(UrlProvider):
found = 0
seen: set[str] = set()
observations: list[EndpointObservation] = []
cap = cfg.max_urls
cap = MAX_URLS
scanned = 0
for domain in domains:
@@ -69,7 +69,7 @@ class ArchiveProvider(UrlProvider):
result.capped = True
result.cap_reason = (
f"{dropped} registrable domains not queried. Limit is "
f"{cfg.max_archive_domains} domains."
f"{MAX_ARCHIVE_DOMAINS} domains."
)
result.urls_found = found
result.hosts_scanned = scanned
+39
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import shutil
import threading
import time
from abc import ABC, abstractmethod
from collections.abc import Callable
@@ -9,9 +10,11 @@ from functools import cached_property
from typing import TYPE_CHECKING, ClassVar
from urllib.parse import urlsplit
from shared.definitions.intensity import Transport
from shared.definitions.vulnerabilities import CoverageStatus
from shared.logging import get_logger
from shared.services.endpoint_inventory import EndpointObservation
from shared.services.scope_filter import matches_any
from shared.utils.datetime import utc_now
from tools.runner import tool_path
@@ -50,6 +53,7 @@ class ProviderContext:
hosts: list[Host]
apex_domains: list[str]
cfg: object
transport: Transport
resolved: ResolvedScanConfig
net: NetOptions
recorder: CommandRecorder | None = None
@@ -143,6 +147,41 @@ class UrlProvider(ABC):
def aborted(self) -> bool:
return self.ctx.is_aborted is not None and self.ctx.is_aborted()
def follow_redirects(self, default: bool) -> bool:
override = self.ctx.resolved.follow_redirects
return default if override is None else override
def path_excluded(self, url: str) -> bool:
excluded = self.ctx.resolved.excluded_paths or []
if not excluded:
return False
try:
path = urlsplit(url).path or "/"
except ValueError:
return True
return matches_any(path, excluded)
def workers(self, cap: int) -> int:
return max(1, min(cap, self.ctx.transport.threads))
def throttle(self) -> None:
"""One request per 1/rate seconds across the provider's workers."""
rate = self.ctx.transport.rate
if not rate:
return
with self._gate:
now = time.monotonic()
wait = self._next_slot - now
if wait > 0:
time.sleep(wait)
now = time.monotonic()
self._next_slot = max(now, self._next_slot) + 1.0 / rate
@cached_property
def _gate(self) -> threading.Lock:
self._next_slot = 0.0
return threading.Lock()
@abstractmethod
def discover(self, result: ProviderResult) -> None:
"""Fill result.observations, and every count the provider actually knows."""
+6 -5
View File
@@ -5,6 +5,7 @@ from dataclasses import dataclass, field
from shared.definitions.endpoints import EndpointSource
from shared.services.endpoint_inventory import EndpointObservation
from stages.url_discovery.config import MAX_URLS
from stages.url_discovery.providers.base import ProviderResult, UrlProvider
from tools.katana.client import KatanaClient, KatanaError
from tools.katana.parser import parse_katana_record
@@ -32,12 +33,12 @@ class KatanaProvider(UrlProvider):
try:
return KatanaClient(
depth=cfg.crawl_depth,
threads=cfg.threads,
timeout=cfg.timeout,
threads=self.ctx.transport.threads,
timeout=self.ctx.transport.timeout,
max_duration_minutes=cfg.max_crawl_minutes,
rate_limit=cfg.rate,
rate_limit=self.ctx.transport.rate,
crawl_scope=cfg.crawl_scope,
include_js=cfg.crawl_javascript,
include_js=True,
headless=cfg.headless,
exclude_extensions=list(cfg.static_extensions)
if cfg.drop_noise
@@ -73,7 +74,7 @@ class KatanaProvider(UrlProvider):
with client.stream_crawl(
targets, should_stop=self.ctx.is_aborted, stderr_sink=_stderr
) as records:
self._ingest(records, state, result, cfg.max_urls)
self._ingest(records, state, result, MAX_URLS)
if fatal:
msg = f"katana could not run: {fatal[0]}"
@@ -11,6 +11,7 @@ import httpx
from shared.definitions.endpoints import EndpointSource
from shared.services.endpoint_inventory import EndpointObservation
from shared.utils.net import host_port
from stages.url_discovery.config import MAX_KNOWN_FILE_HOSTS
from stages.url_discovery.providers.base import ProviderResult, UrlProvider
_ROBOTS = "/robots.txt"
@@ -38,12 +39,12 @@ class KnownFilesProvider(UrlProvider):
roots = _roots(self.ctx.hosts)
if not roots:
return
limit = self.ctx.cfg.max_known_file_hosts
limit = MAX_KNOWN_FILE_HOSTS
selected = roots[:limit]
state = _State()
client = self._client()
try:
workers = min(_MAX_WORKERS, len(selected))
workers = min(self.workers(_MAX_WORKERS), len(selected))
with ThreadPoolExecutor(max_workers=workers) as pool:
for found in pool.map(lambda root: self._mine(client, root), selected):
if found is None:
@@ -71,8 +72,8 @@ class KnownFilesProvider(UrlProvider):
headers = dict(self.ctx.net.headers or {})
headers.setdefault("User-Agent", "reNgine/3.0 (+https://rengine.wiki)")
return httpx.Client(
timeout=self.ctx.cfg.timeout,
follow_redirects=True,
timeout=self.ctx.transport.timeout,
follow_redirects=self.follow_redirects(True),
verify=False, # noqa: S501
proxy=self.ctx.net.proxy_url or None,
headers=headers,
@@ -117,6 +118,10 @@ class KnownFilesProvider(UrlProvider):
return state
def _get(self, client: httpx.Client, url: str, state: _State) -> str | None:
if self.path_excluded(url):
state.refused += 1
return None
self.throttle()
state.fetched += 1
body = bytearray()
try:
+10 -5
View File
@@ -11,6 +11,7 @@ from sqlalchemy import select
from shared.definitions.endpoints import EndpointSource
from shared.models.endpoint import Endpoint
from shared.services.endpoint_inventory import EndpointObservation
from stages.url_discovery.config import MAX_SOURCE_MAPS
from stages.url_discovery.providers import mine
from stages.url_discovery.providers.base import ProviderResult, UrlProvider
@@ -36,11 +37,11 @@ class SourceMapProvider(UrlProvider):
self.progress("no javascript bundle answered")
return
limit = self.ctx.cfg.max_source_maps
limit = MAX_SOURCE_MAPS
selected = bundles[:limit]
client = self._client()
try:
workers = min(_MAX_WORKERS, len(selected))
workers = min(self.workers(_MAX_WORKERS), len(selected))
with ThreadPoolExecutor(max_workers=workers) as pool:
found = list(pool.map(lambda url: self._fetch(client, url), selected))
finally:
@@ -78,10 +79,11 @@ class SourceMapProvider(UrlProvider):
)
.order_by(Endpoint.url)
)
hosts = {h.host for h in self.ctx.hosts}
seen: dict[str, None] = {}
for url in rows:
base = _strip(url)
if base:
if base and (urlsplit(base).hostname or "").lower() in hosts:
seen.setdefault(base, None)
return list(seen)
@@ -89,8 +91,8 @@ class SourceMapProvider(UrlProvider):
headers = dict(self.ctx.net.headers or {})
headers.setdefault("User-Agent", "reNgine/3.0 (+https://rengine.wiki)")
return httpx.Client(
timeout=self.ctx.cfg.timeout,
follow_redirects=True,
timeout=self.ctx.transport.timeout,
follow_redirects=self.follow_redirects(True),
verify=False, # noqa: S501
proxy=self.ctx.net.proxy_url or None,
headers=headers,
@@ -101,6 +103,9 @@ class SourceMapProvider(UrlProvider):
return None
url = f"{bundle}.map"
outcome = _Outcome(bundle=bundle, url=url)
if self.path_excluded(url):
return outcome
self.throttle()
try:
with client.stream("GET", url) as response:
if response.status_code != _OK:
+3
View File
@@ -7,6 +7,7 @@ from sqlalchemy import func, select
from shared.definitions.domains import registrable_domain
from shared.definitions.endpoints import parse_url
from shared.definitions.intensity import TransportTool
from shared.definitions.surface import SurfaceDimension
from shared.definitions.vulnerabilities import CoverageStatus
from shared.enums.scan import AssetKind, Intensity, Phase, StageGroup, StageRole
@@ -60,6 +61,7 @@ class UrlDiscoveryStage(Stage):
produces = frozenset({AssetKind.ENDPOINTS.value})
applies_to = ALL_TARGETS
tools = ("katana", "urlfinder")
transport_tool = TransportTool.KATANA.value
touches_target = False
config_model = UrlDiscoveryConfig
launch_fields = ("enabled", "providers", "crawl_depth", "max_crawl_minutes")
@@ -97,6 +99,7 @@ class UrlDiscoveryStage(Stage):
hosts=hosts,
apex_domains=self._apex_domains(hosts),
cfg=cfg,
transport=self.transport,
resolved=self.ctx.resolved,
net=self.net_options(),
recorder=self.ctx.recorder,
+1 -3
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from pydantic import Field
from shared.definitions.wordlists import WordlistKind
from stages.config import StageConfig, rate, threads, wordlist
from stages.config import StageConfig, wordlist
class VhostConfig(StageConfig):
@@ -17,5 +17,3 @@ class VhostConfig(StageConfig):
title="Wordlist",
description="List of host names to try. Custom lists are uploaded in the Arsenal.",
)
threads: int = threads(30, title="Threads")
rate: int = rate(150, tool="ffuf", title="Requests/s")
+7 -3
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
from sqlalchemy import select
from shared.definitions.intensity import TransportTool
from shared.enums.scan import AssetKind, Phase, StageGroup, StageRole
from shared.enums.subdomain import SubdomainSource
from shared.logging import get_logger
from shared.models.subdomain import Subdomain
from shared.services.scope_filter import matches_any
from shared.services.wordlists import WordlistError, lookup, resolve_path
from shared.utils.datetime import utc_now
from stages.base import DOMAIN_TARGETS, Stage, StageResult
@@ -31,6 +33,7 @@ class VhostStage(Stage):
produces = frozenset({AssetKind.HOSTS.value})
applies_to = DOMAIN_TARGETS
tools = ("ffuf",)
transport_tool = TransportTool.FFUF.value
config_model = VhostConfig
def run(self) -> StageResult:
@@ -66,8 +69,8 @@ class VhostStage(Stage):
try:
client = FfufClient(
wordlist=str(wordlist),
threads=cfg.threads,
rate=cfg.rate,
threads=self.transport.threads,
rate=self.transport.rate or 1,
proxy_url=net.proxy_url,
headers=net.headers,
probe_scheme=net.probe_scheme,
@@ -120,6 +123,7 @@ class VhostStage(Stage):
)
now = utc_now()
added = 0
excluded = self.ctx.resolved.excluded_subdomains or []
for name, ips in found.items():
if name in existing:
continue
@@ -134,7 +138,7 @@ class VhostStage(Stage):
cname=None,
is_active=True,
is_wildcard=False,
is_excluded=False,
is_excluded=matches_any(name, excluded),
discovered_at=now,
)
)
+54 -114
View File
@@ -10,7 +10,6 @@ from shared.definitions.vulnerabilities import (
DEFAULT_SCANNERS,
DEFAULT_SEVERITIES,
DEFAULT_TEMPLATE_SETS,
MAX_FINDINGS_PER_SCAN,
SEVERITY_LABELS,
SEVERITY_ORDER,
SURFACE_LABELS,
@@ -20,7 +19,7 @@ from shared.definitions.vulnerabilities import (
reject_unknown,
)
from shared.models.vuln_template import TemplateSelection
from stages.config import StageConfig, rate, threads, timeout
from stages.config import StageConfig, advanced
_SET_LABELS = {spec.key: spec.label for spec in TEMPLATE_SETS}
@@ -31,15 +30,6 @@ class VulnerabilityScanConfig(StageConfig):
title="Scan for vulnerabilities",
description="Run the selected checks against every asset this scan found.",
)
scanners: list[str] = Field(
default_factory=lambda: list(DEFAULT_SCANNERS),
title="Scanners",
description="Tools that produce findings.",
json_schema_extra={
"options": list(CENSUS_SCANNERS),
"option_labels": dict(CENSUS_SCANNER_LABELS),
},
)
severities: list[str] = Field(
default_factory=lambda: list(DEFAULT_SEVERITIES),
title="Severities",
@@ -66,69 +56,16 @@ class VulnerabilityScanConfig(StageConfig):
description="Selected custom templates run regardless of set or severity.",
json_schema_extra={"widget": "custom_templates"},
)
include_tags: list[str] = Field(
default_factory=list,
max_length=40,
title="Extra tags",
description="Checks carrying any of these tags are added to the selection.",
)
exclude_tags: list[str] = Field(
default_factory=list,
max_length=40,
title="Excluded tags",
description="Checks carrying any of these tags are removed from the selection.",
)
exclude_templates: list[str] = Field(
default_factory=list,
max_length=200,
title="Excluded checks",
description="Check identifiers excluded from every run.",
)
surface: Surface = Field(
default=Surface.WEB,
title="Scan surface",
description="Network services adds every non-web port. Everything adds hostnames for DNS checks.",
json_schema_extra={"option_labels": dict(SURFACE_LABELS)},
)
blind_sweep: bool = Field(
default=True,
title="Remaining checks",
description="Run software-specific checks on origins where that software was not detected. Runs last.",
)
max_targets: int = Field(
default=5000,
ge=1,
le=100000,
title="Origin budget",
description="Origins tested at most, highest ranked first.",
)
rate: int = rate(150, tool="nuclei", title="Request rate (rps)")
threads: int = threads(
25,
title="Concurrency",
description="Checks running in parallel.",
)
bulk_size: int = Field(
default=25,
ge=1,
le=500,
title="Hosts per check",
description="Hosts a single check is run against at once.",
)
timeout: int = timeout(10, title="Request timeout (s)")
retries: int = Field(
default=1,
ge=0,
le=5,
title="Retries",
description="Extra attempts per request.",
)
max_host_error: int = Field(
default=30,
ge=1,
le=500,
title="Host error budget",
description="Errors a host may return before it is dropped. Dropped hosts are listed in coverage.",
headless: bool = Field(
default=False,
title="Browser checks",
description="Run checks that need a rendered page.",
)
max_minutes: int = Field(
default=90,
@@ -137,61 +74,64 @@ class VulnerabilityScanConfig(StageConfig):
title="Time budget (min)",
description="Stop starting batches after this many minutes. Origins not reached are recorded as not scanned. 0 means no limit.",
)
waf_rate_divisor: int = Field(
default=3,
scanners: list[str] = advanced(
None,
default_factory=lambda: list(DEFAULT_SCANNERS),
title="Scanners",
description="Tools that produce findings.",
json_schema_extra={
"options": list(CENSUS_SCANNERS),
"option_labels": dict(CENSUS_SCANNER_LABELS),
},
)
include_tags: list[str] = advanced(
None,
default_factory=list,
max_length=40,
title="Extra tags",
description="Checks carrying any of these tags are added to the selection.",
)
exclude_tags: list[str] = advanced(
None,
default_factory=list,
max_length=40,
title="Excluded tags",
description="Checks carrying any of these tags are removed from the selection.",
)
exclude_templates: list[str] = advanced(
None,
default_factory=list,
max_length=200,
title="Excluded checks",
description="Check identifiers excluded from every run.",
)
blind_sweep: bool = advanced(
True,
title="Remaining checks",
description="Run software-specific checks on origins where that software was not detected. Runs last.",
)
max_targets: int = advanced(
5000,
ge=1,
le=20,
title="WAF rate divisor",
description="Hosts behind a WAF or CDN are scanned at the request rate divided by this. 1 disables.",
le=100000,
title="Origin budget",
description="Origins tested at most, highest ranked first.",
)
honeypot_threshold: int = Field(
default=15,
ge=0,
le=200,
title="Honeypot threshold",
description="Discard a host matching more distinct checks than this. 0 disables.",
)
headless: bool = Field(
default=False,
title="Browser checks",
description="Run checks that need a rendered page.",
)
interactsh: bool = Field(
default=False,
interactsh: bool = advanced(
False,
title="Out-of-band testing",
description="Detect blind injection through callbacks to an external server. Uses the public interact.sh unless a self-hosted server is set.",
)
interactsh_server: str = Field(
default="",
interactsh_server: str = advanced(
"",
max_length=200,
title="Self-hosted OAST server",
description="Empty uses the public server. The token is set under API keys.",
description="Empty uses the public server. The token is set under API keys. Fuzzing uses the same server.",
)
oast_wait_minutes: int = Field(
default=5,
ge=0,
le=120,
title="Keep listening (minutes)",
description="Minutes to wait for a callback after the scan.",
)
store_evidence: bool = Field(
default=True,
store_evidence: bool = advanced(
True,
title="Store request and response",
description="Keep the request and response that produced each finding.",
)
max_evidence_recovery: int = Field(
default=100,
ge=0,
le=2000,
title="Evidence recovery budget",
description="Findings re-checked at one request per second to retain the exchange they matched on. Network and TLS checks report a match without one. 0 disables.",
)
max_findings: int = Field(
default=MAX_FINDINGS_PER_SCAN,
ge=1,
le=MAX_FINDINGS_PER_SCAN,
title="Finding budget",
description="Stop recording after this many findings in one scan.",
description="Keep the exchange that produced each finding. Fuzzing follows the same setting.",
)
@field_validator("severities")
@@ -8,6 +8,7 @@ from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, ClassVar
from shared.definitions.intensity import Transport
from shared.definitions.vulnerabilities import CoverageStatus
from shared.utils.datetime import utc_now
from tools.runner import tool_path
@@ -24,6 +25,9 @@ if TYPE_CHECKING:
from tools.runner.models import CommandRecorder
VULN_STAGE = "vulnerability_scan"
@dataclass
class ScannerContext:
session: Session
@@ -31,6 +35,7 @@ class ScannerContext:
target_id: uuid.UUID
project_id: uuid.UUID
cfg: VulnerabilityScanConfig
transport: Transport
resolved: ResolvedScanConfig
net: NetOptions
surface: SurfacePlan
@@ -62,6 +67,16 @@ class ScannerContext:
def aborted(self) -> bool:
return bool(self.is_aborted is not None and self.is_aborted())
def keep_evidence(self) -> bool:
"""The vulnerability scan's evidence setting, shared by every scanner stage."""
own = getattr(self.cfg, "store_evidence", None)
if own is not None:
return bool(own)
resolved = self.resolved
if resolved is None or not hasattr(resolved, "stage"):
return True
return bool(resolved.stage(VULN_STAGE).get("store_evidence", True))
@dataclass
class Coverage:
+45 -20
View File
@@ -9,6 +9,7 @@ from collections.abc import Iterable
from concurrent.futures import ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from pathlib import Path
from typing import ClassVar
from shared.definitions.scan_surface import (
BATCH_OVERRUN,
@@ -49,6 +50,7 @@ from stages.base import StageAbortedError
from stages.sink import ResultSink
from stages.vulnerability_scan import evidence
from stages.vulnerability_scan.scanners.base import (
VULN_STAGE,
Coverage,
ScannerResult,
VulnScanner,
@@ -64,6 +66,10 @@ _DRAIN_SECONDS = 2.0
_STANDARD = "Standard rate"
_REDUCED = "Reduced rate · WAF or CDN"
_RECOVERY_RATE = 1
BULK_SIZE = 25
MAX_HOST_ERROR = 30
WAF_RATE_DIVISOR = 3
OAST_WAIT_SECONDS = 300
# a job needs at least this long to be worth starting
_MIN_JOB_SECONDS = 60
_BUDGET = "budget"
@@ -137,6 +143,9 @@ class NucleiScanner(VulnScanner):
name = Scanner.NUCLEI.value
label = SCANNER_LABELS[Scanner.NUCLEI.value]
binary = "nuclei"
honeypot_threshold: ClassVar[int] = 15
evidence_recovery: ClassVar[int] = 100
fuzz_param_frequency: ClassVar[int | None] = None
def run(self) -> ScannerResult:
ctx = self.ctx
@@ -174,7 +183,6 @@ class NucleiScanner(VulnScanner):
self, plan: SurfacePlan, tiers: TierPlan, library: _Templates
) -> dict[str, list[Job]]:
"""Every invocation, in the order the budget is spent, per rate lane."""
cfg = self.ctx.cfg
rates = self._rates(plan)
lanes: dict[str, list[Job]] = {_STANDARD: [], _REDUCED: []}
counter = {_STANDARD: 0, _REDUCED: 0}
@@ -184,7 +192,7 @@ class NucleiScanner(VulnScanner):
if not files or not items:
return
per_host = cost(rows)
for lane, wanted in _by_lane(items, cfg.waf_rate_divisor).items():
for lane, wanted in _by_lane(items, WAF_RATE_DIVISOR).items():
rate = rates[lane]
for chunk in batches(wanted, per_host, rate):
counter[lane] += 1
@@ -207,16 +215,22 @@ class NucleiScanner(VulnScanner):
_add(Tier.NAMES.value, plan.names, tiers.names, NAME_TYPES)
for group in tech_groups(plan.roots, tiers):
_add(Tier.MATCHED.value, group.items, group.rows)
_add(Tier.BLIND.value, plan.roots, tiers.product)
if getattr(self.ctx.cfg, "blind_sweep", True):
_add(Tier.BLIND.value, plan.roots, tiers.product)
return {lane: jobs for lane, jobs in lanes.items() if jobs}
def _rates(self, plan: SurfacePlan) -> dict[str, int]:
"""The lane rates: one ceiling, split only when a guarded lane exists."""
cfg = self.ctx.cfg
guarded = cfg.waf_rate_divisor > 1 and any(
item.guarded for item in (*plan.every_root, *plan.services, *plan.names)
scheduled = (
*plan.every_root,
*plan.services,
*plan.names,
*plan.requests,
*plan.bases,
)
standard, reduced = _rate_plan(cfg.rate, cfg.waf_rate_divisor, guarded)
guarded = WAF_RATE_DIVISOR > 1 and any(item.guarded for item in scheduled)
rate = self.ctx.transport.rate or 1
standard, reduced = _rate_plan(rate, WAF_RATE_DIVISOR, guarded)
return {_STANDARD: standard, _REDUCED: reduced}
# ---------- lanes ----------
@@ -310,28 +324,39 @@ class NucleiScanner(VulnScanner):
return NucleiOptions(
templates_file=str(listing),
rate=job.rate,
concurrency=cfg.threads,
bulk_size=cfg.bulk_size,
timeout=cfg.timeout,
retries=cfg.retries,
max_host_error=cfg.max_host_error,
concurrency=ctx.transport.threads,
bulk_size=BULK_SIZE,
timeout=ctx.transport.timeout,
retries=ctx.transport.retries,
max_host_error=MAX_HOST_ERROR,
max_minutes=minutes,
headless=cfg.headless,
interactsh=cfg.interactsh,
interactsh_server=_oast_server(cfg.interactsh_server),
interactsh_server=_oast_server(self._oast_server_value(ctx)),
interactsh_token=self._oast_token(ctx) if cfg.interactsh else None,
oast_wait_seconds=cfg.oast_wait_minutes * 60,
honeypot_threshold=cfg.honeypot_threshold,
oast_wait_seconds=OAST_WAIT_SECONDS,
honeypot_threshold=self.honeypot_threshold,
proxy_url=ctx.net.proxy_url,
headers=dict(ctx.net.headers or {}),
exclude_hosts=tuple(ctx.resolved.excluded_subdomains or ()),
follow_redirects=ctx.resolved.follow_redirects,
protocol_types=job.types,
dast=job.dast,
fuzz_param_frequency=getattr(cfg, "fuzz_param_frequency", None),
fuzz_param_frequency=self.fuzz_param_frequency,
extra_args=ctx.resolved.tool_args("nuclei"),
)
@staticmethod
def _oast_server_value(ctx) -> str | None:
"""The vulnerability scan's server setting, shared by every nuclei stage."""
own = getattr(ctx.cfg, "interactsh_server", None)
if own:
return own
resolved = getattr(ctx, "resolved", None)
if resolved is None or not hasattr(resolved, "stage"):
return None
return resolved.stage(VULN_STAGE).get("interactsh_server")
@staticmethod
def _oast_token(ctx) -> str | None:
"""The self-hosted server's token."""
@@ -357,7 +382,7 @@ class NucleiScanner(VulnScanner):
custom_templates=library.custom,
hosts_total=len(job.items),
rate_limit=job.rate,
concurrency=cfg.threads,
concurrency=ctx.transport.threads,
)
listing = write_template_list(job.templates)
try:
@@ -384,7 +409,7 @@ class NucleiScanner(VulnScanner):
sink = ResultSink(_write, rows=_FLUSH_EVERY, seconds=_FLUSH_SECONDS)
def _on_finding(finding: Finding) -> None:
if cfg.store_evidence:
if ctx.keep_evidence():
finding.request = redact_secrets(finding.request, secrets)
finding.response = redact_secrets(finding.response, secrets)
else:
@@ -516,9 +541,9 @@ class NucleiScanner(VulnScanner):
def _recover_evidence(self) -> None:
"""Re-run the checks whose match nuclei reported without an exchange."""
ctx = self.ctx
if not ctx.cfg.store_evidence or ctx.aborted():
if not self.evidence_recovery or not ctx.keep_evidence() or ctx.aborted():
return
gaps = evidence.pending(ctx.session, ctx.scan_id, ctx.cfg.max_evidence_recovery)
gaps = evidence.pending(ctx.session, ctx.scan_id, self.evidence_recovery)
if not gaps:
return
covered = sum(len(gap.fingerprints) for gap in gaps)
+5 -1
View File
@@ -2,9 +2,11 @@ from __future__ import annotations
import uuid
from shared.definitions.intensity import TransportTool
from shared.definitions.scan_surface import ROOT_TIERS
from shared.definitions.surface import SurfaceDimension
from shared.definitions.vulnerabilities import (
MAX_FINDINGS_PER_SCAN,
SEVERITY_LABELS,
CoverageStatus,
Severity,
@@ -61,6 +63,7 @@ class VulnerabilityScanStage(Stage):
produces = frozenset({AssetKind.VULNERABILITIES.value})
applies_to = ALL_TARGETS
tools = ("nuclei",)
transport_tool = TransportTool.NUCLEI.value
touches_target = True
config_model = VulnerabilityScanConfig
launch_fields = (
@@ -92,7 +95,7 @@ class VulnerabilityScanStage(Stage):
index = vuln_inventory.build_index(self.session, self.ctx.scan_id)
stored: list[str] = []
budget = cfg.max_findings
budget = MAX_FINDINGS_PER_SCAN
def _store(findings: list) -> int:
self._check_abort()
@@ -134,6 +137,7 @@ class VulnerabilityScanStage(Stage):
target_id=self.ctx.target_id,
project_id=self.ctx.project_id,
cfg=cfg,
transport=self.transport,
resolved=self.ctx.resolved,
net=self.net_options(),
surface=plan,
+5 -3
View File
@@ -6,12 +6,13 @@ from types import SimpleNamespace
import pytest
from shared.definitions.intensity import Transport
from shared.definitions.scan_surface import Tier
from shared.services.scan_surface import SurfaceItem, SurfacePlan, by_origin
from shared.services.scan_surface.requests import _better, _rewrite
from stages.dast_scan.config import DastScanConfig
from stages.dast_scan.scanners import scanners
from stages.dast_scan.scanners.nuclei import NucleiDastScanner
from stages.dast_scan.scanners.nuclei import FUZZ_PARAM_FREQUENCY, NucleiDastScanner
from stages.registry import execution_plan, stage_by_name
from stages.vulnerability_scan.scanners.base import ScannerContext
from stages.vulnerability_scan.scanners.nuclei import Job, _Templates
@@ -42,6 +43,7 @@ def _scanner(**cfg) -> NucleiDastScanner:
target_id=uuid.uuid4(),
project_id=uuid.uuid4(),
cfg=DastScanConfig(**cfg),
transport=Transport(tool="nuclei", rate=150, threads=25, timeout=10, retries=1),
resolved=None,
net=None,
surface=SurfacePlan(),
@@ -156,7 +158,7 @@ def test_a_fuzzing_finding_replays_the_same_request_on_each_equivalent():
def test_a_fuzzing_job_passes_dast_and_the_parameter_patience():
scanner = _scanner(fuzz_param_frequency=7)
scanner = _scanner()
scanner.ctx.net = SimpleNamespace(proxy_url=None, headers={})
scanner.ctx.resolved = SimpleNamespace(
follow_redirects=None, tool_args=lambda _t: [], excluded_subdomains=[]
@@ -173,4 +175,4 @@ def test_a_fuzzing_job_passes_dast_and_the_parameter_patience():
options = scanner._options(job, Path("t.txt"), None)
assert options.dast is True
assert options.fuzz_param_frequency == 7
assert options.fuzz_param_frequency == FUZZ_PARAM_FREQUENCY
+68
View File
@@ -0,0 +1,68 @@
"""The built-in engine resolves and runs subdomain discovery on subfinder alone."""
from __future__ import annotations
from typing import ClassVar
import pytest
from shared.definitions.default_engine import (
DEFAULT_ENGINE_INTENSITY,
DEFAULT_ENGINE_NAME,
DEFAULT_PASSIVE_SOURCES,
DEFAULT_VULN_SCANNERS,
SUBDOMAIN_STAGE,
VULNERABILITY_STAGE,
default_engine_stages,
)
from shared.services.scan_resolve import merge_engine_context
from stages.registry import stage_by_name
from stages.subdomain.config import SubdomainConfig
from stages.vulnerability_scan.config import VulnerabilityScanConfig
pytestmark = pytest.mark.pipeline
class _Builtin:
name = DEFAULT_ENGINE_NAME
intensity = DEFAULT_ENGINE_INTENSITY
global_headers: ClassVar[list] = []
stages: ClassVar[dict] = default_engine_stages()
tool_options: ClassVar[dict] = {}
_DELTA = {
SUBDOMAIN_STAGE: {"passive_tools"},
VULNERABILITY_STAGE: {"enabled", "scanners"},
}
def test_the_delta_is_the_sources_and_the_scanner():
assert default_engine_stages() == {
SUBDOMAIN_STAGE: {"passive_tools": list(DEFAULT_PASSIVE_SOURCES)},
VULNERABILITY_STAGE: {"enabled": True, "scanners": list(DEFAULT_VULN_SCANNERS)},
}
assert SubdomainConfig(
**default_engine_stages()[SUBDOMAIN_STAGE]
).enabled_sources == ["subfinder"]
assert VulnerabilityScanConfig(
**default_engine_stages()[VULNERABILITY_STAGE]
).enabled
def test_every_other_stage_runs_at_its_default():
resolved = merge_engine_context(_Builtin(), None, "example.com", "domain")
for spec in stage_by_name().values():
if spec.catalog_hidden:
continue
expected = spec.defaults
got = resolved.stage(spec.name)
for key, value in expected.items():
if key in _DELTA.get(spec.name, set()):
continue
assert got[key] == value, f"{spec.name}.{key}"
assert resolved.stage(SUBDOMAIN_STAGE)["passive_tools"] == ["subfinder"]
assert resolved.stage(VULNERABILITY_STAGE)["enabled"] is True
assert resolved.stage(VULNERABILITY_STAGE)["scanners"] == ["nuclei"]
assert resolved.stage("dast_scan")["enabled"] is False
assert resolved.intensity == DEFAULT_ENGINE_INTENSITY
+6 -2
View File
@@ -7,10 +7,12 @@ from types import SimpleNamespace
import pytest
from shared.definitions.intensity import Transport
from shared.definitions.scan_surface import Tier
from shared.definitions.vulnerabilities import Scanner
from shared.services.scan_surface import SurfaceItem, SurfacePlan, split
from stages.vulnerability_scan.config import VulnerabilityScanConfig
from stages.vulnerability_scan.scanners import nuclei as nuclei_scanner
from stages.vulnerability_scan.scanners.base import (
Coverage,
ScannerContext,
@@ -63,6 +65,7 @@ def _scanner(writes: _Writes, **cfg) -> NucleiScanner:
target_id=uuid.uuid4(),
project_id=uuid.uuid4(),
cfg=VulnerabilityScanConfig(**cfg),
transport=Transport(tool="nuclei", rate=150, threads=25, timeout=10, retries=1),
resolved=None,
net=None,
surface=SurfacePlan(),
@@ -202,9 +205,10 @@ def test_guarded_items_take_the_reduced_lane_only_with_a_divisor():
assert len(single[STANDARD]) == 2
def test_the_schedule_spends_the_budget_in_tier_order():
def test_the_schedule_spends_the_budget_in_tier_order(monkeypatch):
writes = _Writes()
scanner = _scanner(writes, waf_rate_divisor=1)
monkeypatch.setattr(nuclei_scanner, "WAF_RATE_DIVISOR", 1)
scanner = _scanner(writes)
rep = _item("https://rep.example", members=2)
rep.tags = ["geoserver"]
plan = SurfacePlan(roots=[rep])
+9 -10
View File
@@ -5,11 +5,16 @@ from types import SimpleNamespace
import pytest
from shared.definitions.intensity import Transport
from shared.enums.api_key import APIProvider
from shared.models.api_key import API_PROVIDER_META
from shared.services.scan_resolve import MASK, redact_command
from stages.vulnerability_scan.config import VulnerabilityScanConfig
from stages.vulnerability_scan.scanners.nuclei import Job, NucleiScanner, _oast_server
from stages.vulnerability_scan.scanners.nuclei import (
OAST_WAIT_SECONDS,
Job,
NucleiScanner,
_oast_server,
)
from tools.nuclei.client import _EVICTION_SLACK, NucleiClient, NucleiOptions
pytestmark = pytest.mark.pipeline
@@ -49,7 +54,7 @@ def test_no_window_leaves_nuclei_on_its_own_defaults():
def test_the_default_waits_minutes_not_seconds():
assert VulnerabilityScanConfig().oast_wait_minutes >= 1
assert OAST_WAIT_SECONDS >= 60
def test_the_token_reaches_nuclei():
@@ -77,18 +82,12 @@ def test_the_token_is_not_even_read_when_oast_is_off():
scanner = NucleiScanner.__new__(NucleiScanner)
scanner.ctx = SimpleNamespace(
cfg=SimpleNamespace(
threads=1,
bulk_size=1,
timeout=1,
retries=0,
max_host_error=1,
max_minutes=0,
headless=False,
interactsh=False,
interactsh_server="",
oast_wait_minutes=5,
honeypot_threshold=0,
),
transport=Transport(tool="nuclei", rate=150, threads=25, timeout=10, retries=1),
net=SimpleNamespace(proxy_url=None, headers={}),
resolved=SimpleNamespace(
follow_redirects=None, tool_args=lambda _t: [], excluded_subdomains=[]
+191
View File
@@ -0,0 +1,191 @@
"""Every engine field is a decision a stage reads; transport comes from intensity."""
from __future__ import annotations
import re
from pathlib import Path
from typing import ClassVar
import pytest
from shared.definitions.intensity import (
PROFILES,
RATE_TOOLS,
TOOL_TIMEOUT,
Transport,
transport_for,
)
from shared.enums.scan import Intensity, StageRole
from shared.services.scan_resolve import merge_engine_context
from stages.config import FieldTier
from stages.registry import rate_tools, stages
pytestmark = pytest.mark.pipeline
_ROOT = Path(__file__).resolve().parents[2]
_NO_TRANSPORT = {"waf_detect"}
_TRANSPORT_NAMES = {
"rate",
"threads",
"timeout",
"retries",
"dns_threads",
"dns_timeout",
}
_READ_ELSEWHERE = {
("dast_scan", "interactsh"),
("url_discovery", "static_extensions"),
("url_discovery", "ignored_params"),
("url_discovery", "keep_per_family"),
("url_discovery", "sibling_cap"),
("url_discovery", "drop_noise"),
}
def _package_source(spec) -> str:
package = Path(spec.stage_cls.__module__.replace(".", "/")).parent
files = (_ROOT / package).rglob("*.py")
return "\n".join(p.read_text() for p in files if p.name != "config.py")
def _config_source(spec) -> str:
return (
_ROOT / Path(spec.config_model.__module__.replace(".", "/") + ".py")
).read_text()
@pytest.mark.parametrize("spec", stages(), ids=lambda s: s.name)
def test_every_field_is_read_by_its_stage(spec):
body = _package_source(spec)
own = _config_source(spec)
for name in spec.config_model.model_fields:
if name == "enabled" or (spec.name, name) in _READ_ELSEWHERE:
continue
declared = len(re.findall(rf"^ {name}:", own, re.M))
used_in_config = len(re.findall(rf"\b{name}\b", own)) - declared
used = re.search(rf"\.{name}\b|\"{name}\"", body) or used_in_config > 0
assert used, f"{spec.name}.{name} is on the form and read by nothing"
@pytest.mark.parametrize("spec", stages(), ids=lambda s: s.name)
def test_no_stage_carries_a_transport_field(spec):
fields = set(spec.config_model.model_fields)
assert not (fields & _TRANSPORT_NAMES), (
f"{spec.name} declares {fields & _TRANSPORT_NAMES}"
)
@pytest.mark.parametrize("spec", stages(), ids=lambda s: s.name)
def test_every_stage_that_sends_traffic_declares_its_transport(spec):
if spec.touches_target and spec.tools and spec.name not in _NO_TRANSPORT:
assert spec.transport_tool is not None, spec.name
@pytest.mark.parametrize("spec", stages(), ids=lambda s: s.name)
def test_tiers_are_declared_once_per_field(spec):
tiers = spec.config_model.tiers()
assert set(tiers.values()) <= {FieldTier.BASIC.value, FieldTier.ADVANCED.value}
assert tiers["enabled"] == FieldTier.BASIC.value
def test_rate_tools_are_the_tools_with_a_rate():
assert set(rate_tools()) <= set(RATE_TOOLS)
assert "dnsx" not in rate_tools()
@pytest.mark.parametrize(
("tool", "rate", "threads"),
[
("httpx", 150, 150),
("naabu", 1000, 100),
("nuclei", 150, 25),
("katana", 150, 50),
("ffuf", 150, 40),
("dnsx", None, 30),
("banner", None, 32),
],
)
def test_normal_keeps_the_numbers_every_stage_shipped_with(tool, rate, threads):
assert PROFILES[Intensity.NORMAL.value][tool] == (rate, threads)
assert TOOL_TIMEOUT[tool] >= 1
def test_aggressive_is_louder_than_normal_for_every_rated_tool():
for tool in RATE_TOOLS:
normal = PROFILES[Intensity.NORMAL.value][tool]
loud = PROFILES[Intensity.AGGRESSIVE.value][tool]
assert loud[0] > normal[0], tool
assert loud[1] > normal[1], tool
def test_a_weight_scales_the_profile_not_the_ceiling():
third = transport_for("nuclei", Intensity.NORMAL.value, rate_weight=1 / 3)
assert third.rate == 50
capped = transport_for(
"nuclei", Intensity.AGGRESSIVE.value, rate_weight=1 / 3, ceiling=30
)
assert capped.rate == 10
def test_the_context_multipliers_and_ceiling_still_apply():
t = transport_for(
"httpx",
Intensity.NORMAL.value,
thread_multiplier=2.0,
timeout_multiplier=3.0,
rate_override=40,
)
assert t == Transport(tool="httpx", rate=40, threads=300, timeout=30, retries=0)
class _Engine:
intensity = Intensity.NORMAL.value
global_headers: ClassVar[list] = []
stages: ClassVar[dict] = {}
tool_options: ClassVar[dict] = {}
class _Context:
auth: ClassVar[dict] = {"auth_type": "none"}
extra_headers: ClassVar[list] = []
global_rate_limit_override = 20
per_tool_rate_overrides: ClassVar[dict] = {"naabu": 500}
thread_multiplier = 1.0
timeout_multiplier = 1.0
excluded_subdomains: ClassVar[list] = []
excluded_paths: ClassVar[list] = []
excluded_ips: ClassVar[list] = []
included_subdomains: ClassVar[list] = []
follow_redirects_override = None
http_protocol = "both"
def test_the_resolver_hands_every_tool_stage_a_transport():
resolved = merge_engine_context(_Engine(), _Context(), "example.com", "domain")
for spec in stages():
if spec.transport_tool is None:
assert spec.name not in resolved.transports
continue
transport = Transport(**resolved.transports[spec.name])
assert transport.tool == spec.transport_tool
if transport.rate is not None:
assert transport.rate <= 20
assert resolved.per_tool_rate_limits["naabu"] == 20
assert resolved.per_tool_rate_limits["httpx"] == 20
def test_aggressive_moves_the_footprint():
normal = merge_engine_context(_Engine(), None, "example.com", "domain")
loud = merge_engine_context(
_Engine(), None, "example.com", "domain", intensity=Intensity.AGGRESSIVE.value
)
assert (
loud.transports["http_probe"]["rate"] > normal.transports["http_probe"]["rate"]
)
assert loud.transports["port_scan"]["rate"] > normal.transports["port_scan"]["rate"]
def test_support_stages_are_never_launch_decisions():
for spec in stages():
if spec.role == StageRole.SUPPORT.value:
assert "enabled" in spec.config_model.model_fields
@@ -0,0 +1,35 @@
"""Tool args add flags; they never restate one the stage set."""
from __future__ import annotations
import pytest
from tools.runner.executor import merge_extra_args
pytestmark = pytest.mark.pipeline
def test_a_restated_flag_and_its_value_are_dropped():
stage = ["-rate-limit", "5", "-threads", "20"]
extra = ["-rate-limit", "9999", "-retries", "3"]
assert merge_extra_args(stage, extra) == ["-retries", "3"]
def test_the_equals_spelling_is_dropped_too():
assert merge_extra_args(["-timeout", "10"], ["-timeout=99", "-silent"]) == [
"-silent"
]
def test_reserved_flags_are_dropped_before_the_runner_adds_them():
extra = ["-l", "/etc/passwd", "-json", "-o", "elsewhere.txt", "-stats"]
assert merge_extra_args([], extra, ("-l", "-json", "-o")) == ["-stats"]
def test_a_boolean_flag_does_not_swallow_the_next_flag():
assert merge_extra_args(["-silent"], ["-silent", "-stats"]) == ["-stats"]
def test_unrelated_flags_pass_through_in_order():
extra = ["-stats", "-si", "5", "-tags", "cve"]
assert merge_extra_args(["-rate-limit", "5"], extra) == extra
+41 -2
View File
@@ -47,6 +47,33 @@ def _terminate(proc: subprocess.Popen) -> None:
proc.wait(timeout=_KILL_GRACE_SECONDS)
def _flag_name(token: str) -> str:
return token.split("=", 1)[0]
def merge_extra_args(
args: list[str], extra: list[str], reserved: tuple[str, ...] = ()
) -> list[str]:
"""Tool args, minus any flag the stage already set."""
taken = {_flag_name(a) for a in args if a.startswith("-")} | set(reserved)
out: list[str] = []
skip = False
for index, token in enumerate(extra):
if skip:
skip = False
continue
if token.startswith("-") and _flag_name(token) in taken:
following = extra[index + 1] if index + 1 < len(extra) else None
skip = (
"=" not in token
and following is not None
and not following.startswith("-")
)
continue
out.append(token)
return out
def _drain(stream, into: list[str]) -> None:
with contextlib.suppress(Exception):
into.append(stream.read())
@@ -208,7 +235,13 @@ class CLIToolRunner:
args = list(args) if args else []
recorder = recorder if recorder is not None else self._recorder
tool = tool if tool is not None else self._tool
args.extend(extra_args if extra_args is not None else self._extra_args)
args.extend(
merge_extra_args(
args,
extra_args if extra_args is not None else self._extra_args,
(input_flag, output_flag, json_flag, silent_flag),
)
)
input_file: Path | None = None
output_file: Path | None = None
@@ -366,7 +399,13 @@ class CLIToolRunner:
args = list(args) if args else []
recorder = recorder if recorder is not None else self._recorder
tool = tool if tool is not None else self._tool
args.extend(extra_args if extra_args is not None else self._extra_args)
args.extend(
merge_extra_args(
args,
extra_args if extra_args is not None else self._extra_args,
(input_flag, json_flag, silent_flag),
)
)
input_file: Path | None = None
start_time = time.monotonic()
+13 -6
View File
@@ -11,6 +11,7 @@ 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.intensity import Transport
from shared.definitions.vulnerabilities import CoverageStatus
from shared.logging import get_logger
from shared.models.endpoint import Endpoint, EndpointCoverage
@@ -21,7 +22,8 @@ from shared.services.asset_query.lead_cache import bump_sync
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 stages.endpoint_probe.config import FOLLOW_REDIRECTS
from stages.endpoint_probe.stage import EndpointProbeStage
from tools.httpx.client import HttpxClient, HttpxError
from tools.httpx.parser import parse_httpx_record
@@ -66,7 +68,12 @@ def verify_branch(
if scan is None:
return {"error": "scan not found"}
resolved = load_resolved(scan.execution_config)
cfg = EndpointProbeConfig()
stored = (resolved.transports or {}).get(EndpointProbeStage.name)
transport = (
Transport(**stored)
if stored
else EndpointProbeStage.default_transport(resolved.intensity)
)
candidates = branch_candidates(scan.id, host, dir_path)
excluded = resolved.excluded_paths or []
if excluded:
@@ -84,13 +91,13 @@ def verify_branch(
try:
client = HttpxClient(
rate_limit=cfg.rate,
threads=cfg.threads,
timeout=cfg.timeout,
rate_limit=transport.rate,
threads=transport.threads,
timeout=transport.timeout,
proxy_url=resolved.proxy_url,
headers=dict(resolved.headers or {}),
probe_scheme=PROBE_SCHEME.get(resolved.http_protocol),
follow_redirects=cfg.follow_redirects,
follow_redirects=FOLLOW_REDIRECTS,
extra_args=resolved.tool_args("httpx"),
)
except HttpxError as e: