Files
rengine/api/app/services/scan.py
T
Yogesh Ojha a548afffa2 feat(settings): tables that save on change, Users, sheets on their pages
Settings has six tabs: General, API keys, Proxies, Notifications, AI and
Users. Each tab is one card whose controls save on change; list tabs are
tables with a row menu and their Add action in the tab row. A non-admin
sees General and AI, read-only.

Out-of-band testing is the Callback server sheet on the Arsenal Nuclei
tab, with a Need a callback count that filters the library to the same
rows. Bounty Hub settings are a sheet on the Bounty Hub page. The old
settings routes redirect to both.

- API keys store the last test result on the row and clear it when the
  key changes. Providers report whether a test exists. The usage count
  is gone: only ViewDNS ever incremented it.
- Notification channels store the last delivery, written by the
  notifier on its own connection. A channel offers the seven event
  types the product emits, program changes on by default, and levels
  named All events, Warning and critical, Critical only.
- A default proxy applies to a scan launched without a context and is
  preselected for new contexts. A pool hands each scan one endpoint,
  proxy URLs bracket IPv6 hosts, and deleting a proxy clears it from
  its contexts.
- Users can be added with a role, disabled and promoted. An account
  cannot change itself, and one that owns records answers 409.
- Retention windows are validated and include keep forever. The
  instance name cannot be blank and names every page title.
- The AI list drops the two switches nothing read; each feature states
  what it sends. The out-of-band Off help says the checks are skipped.

Connectors, Bounty Hub and Remote control lose their extra page
padding. Migration 5d2e9a8c1b47 adds the test and delivery columns and
drops proxies.mode.

Claude-Session: https://claude.ai/code/session_01RVAHQxZ7JAkoEiTCDiHYue
2026-09-27 10:01:19 +05:30

1843 lines
67 KiB
Python

import asyncio
import copy
import logging
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import replace
from datetime import datetime, timedelta
from typing import Literal
from uuid import UUID
from fastapi import HTTPException, status
from pydantic import ValidationError
from sqlalchemy import (
Select,
case,
cast,
column,
exists,
false,
func,
not_,
nullslast,
or_,
select,
true,
update,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from app.services import scan_deltas as stored_deltas
from app.services.proxy import ProxyService
from app.services.scan_context import ScanContextService
from app.services.scan_engine import ScanEngineService, stage_effects
from app.services.target import TargetService
from shared.config import BaseAppSettings
from shared.definitions.compare import Tone
from shared.definitions.new_checks import NEW_CHECKS_KEY
from shared.definitions.rescan import change_dimension, rescan_label
from shared.definitions.surface import SurfaceDimension
from shared.definitions.vulnerabilities import ACTIONABLE_SEVERITIES, Severity
from shared.definitions.watch import WATCH_HOST_KEY
from shared.enums.activity import ActivityEvent, ActivityLevel
from shared.enums.api_key import APIProvider
from shared.enums.scan import (
SCAN_LIVE_STATUSES,
SCAN_OPEN_STATUSES,
ScanActivityStatus,
ScanScope,
ScanStatus,
)
from shared.models.api_key import APIKey
from shared.models.instance_settings import SINGLETON_KEY, InstanceSettings
from shared.models.recheck import AssetRecheck
from shared.models.scan import (
SCAN_STATUSES,
RecheckFieldCount,
RecheckTally,
RescanSummary,
Scan,
ScanBatchCreate,
ScanCancelAll,
ScanChanges,
ScanCreate,
ScanDay,
ScanExportRow,
ScanFacet,
ScanFindings,
ScanRead,
ScanStats,
ScanStatusCounts,
ScanTargetTrend,
ScanTrendPoint,
fold_pause,
)
from shared.models.scan_activity import ScanActivity, ScanActivityRead
from shared.models.scan_command import (
ScanCommand,
ScanCommandDetail,
ScanCommandRead,
)
from shared.models.scan_context import ScanContext
from shared.models.scan_delta import ScanDelta
from shared.models.scan_engine import ScanEngine
from shared.models.scan_preview import (
PreviewSummary,
PreviewToolStatus,
ScanPreview,
)
from shared.models.subdomain import Subdomain
from shared.models.target import Target
from shared.models.vulnerability import Vulnerability
from shared.services import scan_admission, scan_deltas, target_seeds
from shared.services.activity_log import ActivityLogService
from shared.services.asset_query import QueryScope, vuln_suppressed
from shared.services.celery_dispatch import (
dispatch_scan_admission,
dispatch_scan_finalize,
dispatch_scan_resume,
revoke_scan_tasks,
)
from shared.services.launch_plan import AdHocEngine, plan_label
from shared.services.orchestrator.events import ScanEventPublisher
from shared.services.scan_factory import build_scan_row
from shared.services.scan_resolve import (
MASK,
ResolvedScanConfig,
_auth_summary,
mask_proxy_url,
merge_engine_context,
redact_command,
)
from shared.services.scan_scope import census_only, covering_stages, covers
from shared.utils.datetime import utc_now
from shared.utils.validation import unrecognised_target, validate_target
from stages.registry import resume_point
logger = logging.getLogger(__name__)
_SECONDS_PER_MINUTE = 60
_MINUTES_PER_HOUR = 60
ScanSortKey = Literal["started", "duration", "status", "subdomains", "vulnerabilities"]
ScanSortDir = Literal["asc", "desc"]
_WINDOW_DELTAS = {
"6h": timedelta(hours=6),
"12h": timedelta(hours=12),
"24h": timedelta(days=1),
"7d": timedelta(days=7),
"14d": timedelta(days=14),
"30d": timedelta(days=30),
}
MAX_SCAN_EXPORT = 50000
_RAN = (ScanActivityStatus.SUCCESS.value, ScanActivityStatus.PARTIAL.value)
_SHORT = (ScanActivityStatus.PARTIAL.value, ScanActivityStatus.FAILED.value)
TREND_POINTS = 10
MAX_TREND_TARGETS = 100
MAX_DAILY_WINDOW = 90
_FINDINGS_RANK = (
select(
func.coalesce(
func.sum(
case(
(Vulnerability.severity == Severity.CRITICAL.value, 1_000_000),
(Vulnerability.severity == Severity.HIGH.value, 1_000),
else_=1,
)
),
0,
)
)
.where(
Vulnerability.scan_id == Scan.id,
Vulnerability.severity.in_(ACTIONABLE_SEVERITIES),
)
.correlate(Scan)
.scalar_subquery()
)
_STATUS_RANK = case(
(Scan.status == ScanStatus.RUNNING.value, 0),
(Scan.status == ScanStatus.PENDING.value, 1),
(Scan.status == ScanStatus.COMPLETED.value, 2),
(Scan.status == ScanStatus.FAILED.value, 3),
else_=4,
)
def _resolved_config(scan_id: UUID, masked: dict) -> ResolvedScanConfig:
try:
return ResolvedScanConfig(**masked)
except ValidationError as exc:
bad = {str(e["loc"][0]) for e in exc.errors() if e["loc"]}
logger.warning("scan %s has an unusable execution_config: %s", scan_id, bad)
safe = {"target_value": "", "target_type": ""}
safe.update({k: v for k, v in masked.items() if k not in bad})
return ResolvedScanConfig(**safe)
def _mask_config_headers(config: dict) -> dict:
out = copy.deepcopy(config)
headers = out.get("headers") or {}
out["headers"] = {
name: (MASK if value else value) for name, value in headers.items()
}
if out.get("proxy_url"):
out["proxy_url"] = mask_proxy_url(out["proxy_url"])
if isinstance(out.get("tool_options"), dict):
out["tool_options"] = {
t: redact_command(v) for t, v in out["tool_options"].items()
}
return out
def _command_read(cmd: ScanCommand) -> ScanCommandRead:
read = ScanCommandRead.model_validate(cmd, from_attributes=True)
read.command = redact_command(read.command)
return read
_UNSETTLED_ACTIVITY_STATUSES = (
ScanActivityStatus.RUNNING.value,
ScanActivityStatus.PAUSED.value,
)
def _scan_duration(scan: Scan) -> float | None:
if scan.started_at is None:
return None
end = scan.completed_at or _open_end(scan)
if end is None:
return None
ran = (end - scan.started_at).total_seconds() - (scan.paused_seconds or 0.0)
return round(max(ran, 0.0), 1)
def _open_end(scan: Scan) -> datetime | None:
if scan.status == ScanStatus.RUNNING.value:
return utc_now()
return scan.paused_at if scan.status == ScanStatus.PAUSED.value else None
def _human_duration(seconds: int) -> str:
if seconds < _SECONDS_PER_MINUTE:
return f"{seconds}s"
minutes = seconds // _SECONDS_PER_MINUTE
if minutes < _MINUTES_PER_HOUR:
return f"{minutes}m"
hours = minutes // _MINUTES_PER_HOUR
rem = minutes % _MINUTES_PER_HOUR
return f"{hours}h {rem}m" if rem else f"{hours}h"
def _scans_writing_since(project_id: UUID, since, target_id: UUID | None = None):
"""Scans that could have written a row at or after `since`."""
conds = [
Scan.project_id == project_id,
or_(Scan.completed_at.is_(None), Scan.completed_at >= since),
]
if target_id is not None:
conds.append(Scan.target_id == target_id)
return select(Scan.id).where(*conds)
class ScanService:
def __init__(self, session: AsyncSession):
self.session = session
self.engine_service = ScanEngineService(session)
self.context_service = ScanContextService(session)
async def _get_engine(self, engine_id: UUID, project_id: UUID) -> ScanEngine:
result = await self.session.execute(
select(ScanEngine).where(
ScanEngine.id == engine_id, ScanEngine.project_id == project_id
)
)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scan engine not found"
)
return engine
async def _get_context(self, context_id: UUID, project_id: UUID) -> ScanContext:
result = await self.session.execute(
select(ScanContext).where(
ScanContext.id == context_id, ScanContext.project_id == project_id
)
)
ctx = result.scalar_one_or_none()
if not ctx:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scan context not found"
)
return ctx
async def _get_targets(
self, target_ids: list[UUID], project_id: UUID
) -> list[Target]:
result = await self.session.execute(
select(Target).where(
Target.id.in_(target_ids), Target.project_id == project_id
)
)
found = {t.id: t for t in result.scalars().all()}
missing = [tid for tid in target_ids if tid not in found]
if missing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Target not found: {missing[0]}",
)
return [found[tid] for tid in target_ids]
async def _get_target(self, target_id: UUID, project_id: UUID) -> Target:
result = await self.session.execute(
select(Target).where(
Target.id == target_id, Target.project_id == project_id
)
)
target = result.scalar_one_or_none()
if not target:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Target not found"
)
return target
async def _resolve_scope(
self, engine_id: UUID | None, context_id: UUID | None, project_id: UUID
):
engine = (
await self._get_engine(engine_id, project_id)
if engine_id is not None
else AdHocEngine()
)
context = None
if context_id is not None:
context = await self._get_context(context_id, project_id)
proxy_url = await ProxyService(self.session).scan_proxy_url(context)
return engine, context, proxy_url
@staticmethod
def _resolve_for(
engine,
context,
target: Target,
proxy_url: str | None,
data: ScanCreate | ScanBatchCreate,
stored_seeds: list[dict] | None = None,
):
resolved = merge_engine_context(
engine,
context,
target.target_value,
target.target_type.value,
proxy_url=proxy_url,
overrides=data.overrides,
intensity=data.intensity,
)
seeds = list(getattr(data, "seed_assets", None) or [])
if seeds:
target_seeds.apply(
resolved, [seed.model_dump() for seed in seeds], seed_only=True
)
elif target.seed_scans:
target_seeds.apply(resolved, stored_seeds or [], seed_only=False)
if engine.id is None:
label = (
rescan_label(data.dimension or "", len(seeds))
if seeds
else plan_label(resolved)
)
engine = replace(engine, name=label)
return engine, resolved
async def _launch_target(
self, data: ScanCreate, project_id: UUID, created_by: UUID | None
) -> Target:
"""The saved target, created from `target_value` when launching."""
if data.target_id is not None:
return await self._get_target(data.target_id, project_id)
value = (data.target_value or "").strip()
if created_by is not None:
ensured = await TargetService(self.session).ensure_targets(
[value], project_id, created_by
)
return ensured[0]
target_type = validate_target(value)
if target_type is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=unrecognised_target(value),
)
return Target(
target_value=value, target_type=target_type, project_id=project_id
)
async def _resolve_and_validate(
self, data: ScanCreate, project_id: UUID, created_by: UUID | None = None
):
engine, context, proxy_url = await self._resolve_scope(
data.engine_id, data.context_id, project_id
)
target = await self._launch_target(data, project_id, created_by)
stored = await target_seeds.load_async(self.session, [target.id])
engine, resolved = self._resolve_for(
engine, context, target, proxy_url, data, stored.get(target.id)
)
return engine, context, target, resolved
async def _batch_targets(
self, data: ScanBatchCreate, project_id: UUID, created_by: UUID
) -> list[Target]:
targets: list[Target] = []
if data.target_ids:
targets = await self._get_targets(
list(dict.fromkeys(data.target_ids)), project_id
)
if data.target_values:
seen = {t.id for t in targets}
ensured = await TargetService(self.session).ensure_targets(
data.target_values, project_id, created_by
)
targets.extend(t for t in ensured if t.id not in seen)
return targets
async def _configured_providers(self) -> set[str]:
result = await self.session.execute(
select(APIKey).where(APIKey.is_enabled == True) # noqa: E712
)
configured = set()
for key in result.scalars().all():
provider = (
key.provider.value
if isinstance(key.provider, APIProvider)
else key.provider
)
configured.add(provider)
return configured
async def _live_runs(self, target_id: UUID | None, project_id: UUID) -> int:
"""Runs already sending traffic to this target."""
if target_id is None:
return 0
return (
await self.session.scalar(
select(func.count())
.select_from(Scan)
.where(
Scan.target_id == target_id,
Scan.project_id == project_id,
Scan.status.in_(SCAN_LIVE_STATUSES),
)
)
) or 0
async def preview(self, data: ScanCreate, project_id: UUID) -> ScanPreview:
engine, context, target, resolved = await self._resolve_and_validate(
data, project_id
)
configured = await self._configured_providers()
phases, warnings = stage_effects(resolved, configured)
live = await self._live_runs(target.id, project_id)
if live:
warnings.append(
f"{target.target_value} already has {live} run"
f"{'' if live == 1 else 's'} in flight. Both send traffic to it."
)
will_run = sum(
1 for p in phases for t in p.tools if t.status == PreviewToolStatus.WILL_RUN
)
est_seconds = will_run * _SECONDS_PER_MINUTE
to_target = sum(
t.rate or 0
for p in phases
for t in p.tools
if t.status == PreviewToolStatus.WILL_RUN
)
rate_summary = f"~{to_target}/s to target" if to_target else "no target traffic"
if resolved.global_rate_limit_ceiling is not None:
rate_summary += f", ceiling {resolved.global_rate_limit_ceiling}/s per tool"
auth = context.auth if context is not None else {"auth_type": "none"}
extra_headers = context.extra_headers if context is not None else []
custom_header_names = [
h.get("name") for h in (extra_headers or []) if h.get("name")
]
proxy = await ProxyService(self.session).scan_proxy(context)
proxy_name = proxy.name if proxy is not None and proxy.is_active else None
summary = PreviewSummary(
auth_summary=_auth_summary(auth, extra_headers),
custom_header_names=custom_header_names,
rate_summary=rate_summary,
thread_multiplier=resolved.thread_multiplier,
timeout_multiplier=resolved.timeout_multiplier,
http_protocol=resolved.http_protocol,
follow_redirects=resolved.follow_redirects,
excluded_subdomains_count=len(resolved.excluded_subdomains),
excluded_paths_count=len(resolved.excluded_paths),
excluded_ips_count=len(resolved.excluded_ips),
excluded_subdomains=resolved.excluded_subdomains,
excluded_paths=resolved.excluded_paths,
excluded_ips=resolved.excluded_ips,
included_subdomains=resolved.included_subdomains,
seed_count=0 if resolved.seed_only else len(resolved.seed_assets),
proxy_name=proxy_name,
estimated_duration_seconds=est_seconds,
estimated_duration_human=_human_duration(est_seconds),
)
return ScanPreview(
target_id=target.id if data.target_id is not None else None,
target_value=target.target_value,
target_type=target.target_type.value,
engine_id=engine.id,
engine_name=engine.name,
context_id=context.id if context is not None else None,
context_name=context.name if context is not None else None,
phases=phases,
summary=summary,
warnings=warnings,
)
async def create(
self,
data: ScanCreate,
project_id: UUID,
created_by: UUID,
schedule_id: UUID | None = None,
schedule_type: str | None = None,
) -> ScanRead:
engine, context, target, resolved = await self._resolve_and_validate(
data, project_id, created_by
)
if data.new_checks is not None:
target.new_checks = data.new_checks
logger.info(
"Creating scan for target=%s engine=%s header_names=%s",
target.id,
engine.id,
list(resolved.headers.keys()),
)
scan = build_scan_row(
resolved=resolved,
engine=engine,
context=context,
target=target,
project_id=project_id,
created_by=created_by,
schedule_id=schedule_id,
schedule_type=schedule_type,
parent_scan_id=data.parent_scan_id,
run_group_id=data.run_group_id,
dimension=data.dimension,
)
self.session.add(scan)
await self.session.commit()
await self.session.refresh(scan)
if engine.id is not None:
await self.engine_service.touch(engine.id, project_id)
if context is not None:
await self.context_service.touch(context.id, project_id, scan_id=scan.id)
await self._dispatch_batch([scan])
return self._to_read(scan)
async def create_batch(
self,
data: ScanBatchCreate,
project_id: UUID,
created_by: UUID,
) -> list[ScanRead]:
engine, context, proxy_url = await self._resolve_scope(
data.engine_id, data.context_id, project_id
)
targets = await self._batch_targets(data, project_id, created_by)
if data.new_checks is not None:
for target in targets:
target.new_checks = data.new_checks
stored = await target_seeds.load_async(
self.session, [target.id for target in targets]
)
scans: list[Scan] = []
for target in targets:
run_engine, resolved = self._resolve_for(
engine, context, target, proxy_url, data, stored.get(target.id)
)
scan = build_scan_row(
resolved=resolved,
engine=run_engine,
context=context,
target=target,
project_id=project_id,
created_by=created_by,
)
self.session.add(scan)
scans.append(scan)
logger.info("Creating %d scans for engine=%s", len(scans), engine.id)
await self.session.commit()
if engine.id is not None:
await self.engine_service.touch(engine.id, project_id)
if context is not None:
await self.context_service.touch(
context.id, project_id, scan_id=scans[-1].id
)
await self._dispatch_batch(scans)
return [self._to_read(scan) for scan in scans]
def _dispatch_scan(self, scan: Scan) -> None:
from shared.services.celery_dispatch import dispatch_scan_run # noqa: PLC0415
dispatch_scan_run(str(scan.id), scan.run_epoch)
def _dispatch_each(self, scans: list[Scan]) -> list[Scan]:
failed = []
for scan in scans:
try:
self._dispatch_scan(scan)
except Exception:
logger.exception("Failed to dispatch scan %s", scan.id)
failed.append(scan)
return failed
async def _dispatch_batch(self, scans: list[Scan]) -> None:
failed = await asyncio.to_thread(self._dispatch_each, scans)
if not failed:
return
for scan in failed:
scan.status = ScanStatus.FAILED.value
scan.error = "Not queued. Check that the worker is running."
scan.completed_at = utc_now()
await self.session.commit()
def _sort_expr(self, sort_by: ScanSortKey):
if sort_by == "duration":
return func.extract(
"epoch",
func.coalesce(Scan.completed_at, utc_now()) - Scan.started_at,
) - func.coalesce(Scan.paused_seconds, 0.0)
if sort_by == "status":
return _STATUS_RANK
if sort_by == "subdomains":
return Scan.subdomains_found
if sort_by == "vulnerabilities":
return _FINDINGS_RANK
return func.coalesce(Scan.started_at, Scan.created_at)
def _filter_conditions(
self,
m,
statuses: list[str] | None,
engines: list[str] | None,
contexts: list[str] | None,
time_range: str | None,
include_focused: bool = False,
new_checks: bool | None = None,
) -> list:
conds: list = []
follow_up = cast(m.execution_config, JSONB).has_key(NEW_CHECKS_KEY)
if new_checks is True:
conds.append(follow_up)
elif not include_focused:
conds.append(census_only(m))
if new_checks is False:
conds.append(not_(func.coalesce(follow_up, false())))
if statuses:
conds.append(m.status.in_(statuses))
if engines:
conds.append(m.engine_name.in_(engines))
if contexts:
conds.append(m.context_name.in_(contexts))
if time_range and time_range in _WINDOW_DELTAS:
cutoff = utc_now() - _WINDOW_DELTAS[time_range]
conds.append(func.coalesce(m.started_at, m.created_at) >= cutoff)
return conds
def build_list_query(
self,
project_id: UUID,
target_id: UUID | None = None,
statuses: list[str] | None = None,
engines: list[str] | None = None,
contexts: list[str] | None = None,
search: str | None = None,
time_range: str | None = None,
sort_by: ScanSortKey = "started",
sort_dir: ScanSortDir = "desc",
schedule_id: UUID | None = None,
scheduled: bool | None = None,
include_focused: bool = False,
parent_id: UUID | None = None,
new_checks: bool | None = None,
severities: list[str] | None = None,
short: bool | None = None,
added: bool | None = None,
started_from: datetime | None = None,
started_to: datetime | None = None,
latest: bool = False,
) -> Select:
query = select(Scan).where(
Scan.project_id == project_id,
*self._filter_conditions(
Scan,
statuses,
engines,
contexts,
time_range,
include_focused or parent_id is not None,
new_checks,
),
)
started = func.coalesce(Scan.started_at, Scan.created_at)
if started_from is not None:
query = query.where(started >= started_from)
if started_to is not None:
query = query.where(started < started_to)
if severities:
query = query.where(
exists(
select(1).where(
Vulnerability.scan_id == Scan.id,
Vulnerability.severity.in_(severities),
not_(vuln_suppressed(QueryScope(()))),
)
)
)
if short is not None:
fell_short = exists(
select(1).where(
ScanActivity.scan_id == Scan.id,
ScanActivity.status.in_(_SHORT),
)
)
query = query.where(fell_short if short else not_(fell_short))
if added is not None:
grew = exists(
select(1).where(
ScanDelta.scan_id == Scan.id,
ScanDelta.dimension == SurfaceDimension.WEB_ASSETS.value,
ScanDelta.first_seen > 0,
)
)
query = query.where(grew if added else not_(grew))
if parent_id is not None:
query = query.where(Scan.parent_scan_id == parent_id)
if target_id is not None:
query = query.where(Scan.target_id == target_id)
if schedule_id is not None:
query = query.where(Scan.schedule_id == schedule_id)
if scheduled is True:
query = query.where(Scan.schedule_type.is_not(None))
elif scheduled is False:
query = query.where(Scan.schedule_type.is_(None))
if search and search.strip():
term = f"%{search.strip().lower()}%"
query = query.join(Target, Scan.target_id == Target.id).where(
or_(
func.lower(Target.target_value).like(term),
func.lower(Scan.engine_name).like(term),
func.lower(func.coalesce(Scan.context_name, "")).like(term),
)
)
if latest:
ranked = query.with_only_columns(
Scan.id.label("id"),
func.row_number()
.over(partition_by=Scan.target_id, order_by=started.desc())
.label("rn"),
).subquery()
query = select(Scan).where(
Scan.id.in_(select(ranked.c.id).where(ranked.c.rn == 1))
)
expr = self._sort_expr(sort_by)
ordering = expr.asc() if sort_dir == "asc" else expr.desc()
if sort_by == "duration":
ordering = nullslast(ordering)
return query.order_by(ordering, Scan.created_at.desc())
def to_read(self, scan: Scan) -> ScanRead:
return self._to_read(scan)
async def export_rows(
self,
project_id: UUID,
target_id: UUID | None = None,
statuses: list[str] | None = None,
engines: list[str] | None = None,
contexts: list[str] | None = None,
search: str | None = None,
time_range: str | None = None,
sort_by: ScanSortKey = "started",
sort_dir: ScanSortDir = "desc",
scheduled: bool | None = None,
include_focused: bool = False,
new_checks: bool | None = None,
) -> list[ScanExportRow]:
query = self.build_list_query(
project_id=project_id,
target_id=target_id,
statuses=statuses,
engines=engines,
contexts=contexts,
search=search,
time_range=time_range,
sort_by=sort_by,
sort_dir=sort_dir,
scheduled=scheduled,
include_focused=include_focused,
new_checks=new_checks,
).limit(MAX_SCAN_EXPORT)
result = await self.session.execute(query)
return [
ScanExportRow(
target=(scan.execution_config or {}).get("target_value", ""),
status=scan.status,
engine=scan.engine_name,
context=scan.context_name,
schedule_type=scan.schedule_type,
subdomains=scan.subdomains_found,
ips=scan.ips_found,
open_ports=scan.open_ports_found,
vulnerabilities=scan.vulnerabilities_found,
endpoints=scan.endpoints_found,
duration_seconds=_scan_duration(scan),
started_at=scan.started_at,
completed_at=scan.completed_at,
created_at=scan.created_at,
)
for scan in result.scalars().all()
]
async def new_subdomain_counts(
self, scan_ids: list[UUID], live: Iterable[UUID] = ()
) -> dict[UUID, int]:
"""Per scan, the names it was the first to report for its target."""
return await stored_deltas.first_seen(
self.session, SurfaceDimension.WEB_ASSETS.value, scan_ids, live
)
async def prepare_growth(
self, project_id: UUID, target_id: UUID | None = None
) -> None:
"""Store a current first-seen count for every run the list can filter on."""
conds = [Scan.project_id == project_id]
if target_id is not None:
conds.append(Scan.target_id == target_id)
rows = (
await self.session.execute(select(Scan.id, Scan.status).where(*conds))
).all()
await self.new_subdomain_counts(
[r.id for r in rows],
[r.id for r in rows if r.status in SCAN_OPEN_STATUSES],
)
async def prev_completed_counts(
self, scan_ids: list[UUID], target_ids: list[UUID]
) -> dict[UUID, int]:
"""Per scan, the previous COMPLETED scan's subdomains_found for the same target."""
if not scan_ids or not target_ids:
return {}
ordering = func.coalesce(Scan.started_at, Scan.created_at)
prev = (
func.lag(Scan.subdomains_found)
.over(partition_by=Scan.target_id, order_by=ordering.asc())
.label("prev")
)
completed = (
select(Scan.id.label("id"), prev)
.where(
Scan.target_id.in_(target_ids),
Scan.status == ScanStatus.COMPLETED.value,
census_only(),
)
.subquery()
)
rows = (
await self.session.execute(
select(completed.c.id, completed.c.prev).where(
completed.c.id.in_(scan_ids)
)
)
).all()
return {sid: p for sid, p in rows if p is not None}
async def first_scan_ids(self, target_ids: list[UUID]) -> set[UUID]:
"""The earliest scan id for each target."""
if not target_ids:
return set()
ordering = func.coalesce(Scan.started_at, Scan.created_at)
rn = (
func.row_number()
.over(
partition_by=Scan.target_id,
order_by=[ordering.asc(), Scan.created_at.asc()],
)
.label("rn")
)
sub = (
select(Scan.id.label("id"), rn)
.where(Scan.target_id.in_(target_ids), census_only())
.subquery()
)
rows = (await self.session.execute(select(sub.c.id).where(sub.c.rn == 1))).all()
return {r[0] for r in rows}
async def gone_subdomain_counts(
self, scan_ids: list[UUID], target_ids: list[UUID]
) -> dict[UUID, int]:
"""Per scan, subdomain names present in the previous completed run but absent now."""
if not scan_ids or not target_ids:
return {}
runs = scan_deltas.previous_completed(target_ids)
pairs = (
await self.session.execute(
select(runs.c.id, runs.c.prev_id).where(
runs.c.id.in_(scan_ids), runs.c.prev_id.is_not(None)
)
)
).all()
counts = await stored_deltas.retired(
self.session,
SurfaceDimension.WEB_ASSETS.value,
[(scan_id, prev_id) for scan_id, prev_id in pairs],
)
return {scan_id: n for (scan_id, _), n in counts.items()}
async def retired_subdomain_total(
self, project_id: UUID, cutoff: datetime, target_id: UUID | None = None
) -> int:
"""Subdomains dropped by each target's latest completed scan that ran in the window."""
ordering = func.coalesce(Scan.started_at, Scan.created_at)
rn = (
func.row_number()
.over(partition_by=Scan.target_id, order_by=ordering.desc())
.label("rn")
)
conds = [
Scan.project_id == project_id,
Scan.status == ScanStatus.COMPLETED.value,
]
if target_id is not None:
conds.append(Scan.target_id == target_id)
latest = (
select(
Scan.id.label("id"),
Scan.target_id.label("tid"),
ordering.label("t"),
rn,
)
.where(*conds)
.subquery()
)
rows = (
await self.session.execute(
select(latest.c.id, latest.c.tid).where(
latest.c.rn == 1, latest.c.t >= cutoff
)
)
).all()
if not rows:
return 0
scan_ids = [r[0] for r in rows]
target_ids = list({r[1] for r in rows})
gone = await self.gone_subdomain_counts(scan_ids, target_ids)
return sum(gone.values())
async def _first_seen_since(
self, project_id: UUID, cutoff: datetime, target_id: UUID | None
) -> tuple[int, int]:
"""Names first reported at or after the cutoff, and the targets they belong to."""
runs = (
await self.session.execute(
select(
Scan.id,
Scan.target_id,
Scan.status,
func.coalesce(Scan.started_at, Scan.created_at),
).where(
Scan.id.in_(_scans_writing_since(project_id, cutoff, target_id))
)
)
).all()
inside = [r for r in runs if r[3] >= cutoff]
counts = await self.new_subdomain_counts(
[r.id for r in inside],
[r.id for r in inside if r.status in SCAN_OPEN_STATUSES],
)
per_target: dict[UUID, int] = defaultdict(int)
for r in inside:
per_target[r.target_id] += counts.get(r.id, 0)
straddling = [r.id for r in runs if r[3] < cutoff]
if straddling:
rows = await self.session.execute(
select(Subdomain.target_id, func.count())
.where(
Subdomain.scan_id.in_(straddling),
Subdomain.discovered_at >= cutoff,
not_(scan_deltas.seen_earlier(SurfaceDimension.WEB_ASSETS.value)),
)
.group_by(Subdomain.target_id)
)
for tid, n in rows.all():
per_target[tid] += int(n)
return sum(per_target.values()), sum(1 for n in per_target.values() if n)
async def changes(
self, project_id: UUID, window: str, target_id: UUID | None = None
) -> ScanChanges:
cutoff = utc_now() - _WINDOW_DELTAS.get(window, timedelta(days=7))
new_subdomains, targets_changed = await self._first_seen_since(
project_id, cutoff, target_id
)
scan_conds = [Scan.project_id == project_id, Scan.created_at >= cutoff]
if target_id is not None:
scan_conds.append(Scan.target_id == target_id)
scans_run = (
await self.session.execute(select(func.count()).where(*scan_conds))
).scalar_one()
failed_runs = (
await self.session.execute(
select(func.count()).where(
*scan_conds,
Scan.status.in_(
[ScanStatus.FAILED.value, ScanStatus.CANCELLED.value]
),
)
)
).scalar_one()
retired = await self.retired_subdomain_total(project_id, cutoff, target_id)
return ScanChanges(
window=window,
new_subdomains=new_subdomains,
retired_subdomains=retired,
targets_changed=targets_changed,
scans_run=scans_run,
failed_runs=failed_runs,
)
async def stats(
self,
project_id: UUID,
target_id: UUID | None = None,
include_focused: bool = False,
) -> ScanStats:
conds = [Scan.project_id == project_id]
if not include_focused:
conds.append(census_only())
if target_id is not None:
conds.append(Scan.target_id == target_id)
status_rows = (
await self.session.execute(
select(Scan.status, func.count()).where(*conds).group_by(Scan.status)
)
).all()
counts = dict.fromkeys(SCAN_STATUSES, 0)
total = 0
for st, n in status_rows:
if st in counts:
counts[st] = n
total += n
last_scan_at = (
await self.session.execute(select(func.max(Scan.created_at)).where(*conds))
).scalar_one_or_none()
avg_duration = (
await self.session.execute(
select(
func.avg(
func.extract("epoch", Scan.completed_at - Scan.started_at)
- func.coalesce(Scan.paused_seconds, 0.0)
)
).where(
*conds,
Scan.status == ScanStatus.COMPLETED.value,
Scan.started_at.is_not(None),
Scan.completed_at.is_not(None),
)
)
).scalar_one_or_none()
finished = (
counts[ScanStatus.COMPLETED.value]
+ counts[ScanStatus.FAILED.value]
+ counts[ScanStatus.CANCELLED.value]
)
success_rate = (
counts[ScanStatus.COMPLETED.value] / finished if finished else None
)
engine_rows = (
await self.session.execute(
select(Scan.engine_name, func.count())
.where(*conds)
.group_by(Scan.engine_name)
.order_by(func.count().desc())
)
).all()
engines = [ScanFacet(name=name, count=n) for name, n in engine_rows if name]
context_rows = (
await self.session.execute(
select(Scan.context_name, func.count())
.where(*conds, Scan.context_name.is_not(None))
.group_by(Scan.context_name)
.order_by(func.count().desc())
)
).all()
contexts = [ScanFacet(name=name, count=n) for name, n in context_rows if name]
return ScanStats(
total=total,
running=counts[ScanStatus.RUNNING.value],
by_status=ScanStatusCounts(**counts),
last_scan_at=last_scan_at,
avg_duration_seconds=(
round(avg_duration, 1) if avg_duration is not None else None
),
success_rate=round(success_rate, 3) if success_rate is not None else None,
engines=engines,
contexts=contexts,
)
async def get(self, id: UUID, project_id: UUID) -> ScanRead:
scan = await self._get_scan(id, project_id)
read = self._to_read(scan)
await self.attach_deltas([read])
return read
async def attach_deltas(self, items: list[ScanRead]) -> None:
if not items:
return
scan_ids = [i.id for i in items]
target_ids = list({i.target_id for i in items})
new_counts = await self.new_subdomain_counts(
scan_ids, [i.id for i in items if i.status in SCAN_OPEN_STATUSES]
)
gone_counts = await self.gone_subdomain_counts(scan_ids, target_ids)
prev_counts = await self.prev_completed_counts(scan_ids, target_ids)
first_ids = await self.first_scan_ids(target_ids)
rescans = await self.rescan_summaries(scan_ids)
rechecks = await self.recheck_tallies(
[i.id for i in items if i.scope == ScanScope.FOCUSED.value]
)
findings = await self.finding_counts(scan_ids)
queued = await self.queue_positions(
[i.id for i in items if i.status == ScanStatus.PENDING.value]
)
for i in items:
i.queue_position = queued.get(i.id)
i.findings = findings.get(i.id)
i.new_subdomains = new_counts.get(i.id, 0)
i.gone_subdomains = gone_counts.get(i.id, 0)
i.prev_subdomains_found = prev_counts.get(i.id)
i.is_first_scan = i.id in first_ids
i.rescans = rescans.get(i.id)
i.recheck = rechecks.get(i.id)
async def queue_positions(self, scan_ids: list[UUID]) -> dict[UUID, int]:
"""Scans ahead of each pending scan the limit holds back."""
if not scan_ids:
return {}
places = dict(
(await self.session.execute(scan_admission.positions_query(scan_ids))).all()
)
if not places:
return {}
busy = await self.session.scalar(scan_admission.running_query()) or 0
configured = await self.session.scalar(
select(InstanceSettings.concurrent_scans).where(
InstanceSettings.singleton_key == SINGLETON_KEY
)
)
limit = await asyncio.to_thread(scan_admission.resolve, configured)
return {
scan_id: place - 1
for scan_id, place in places.items()
if busy + place - 1 >= limit
}
async def finding_counts(self, scan_ids: list[UUID]) -> dict[UUID, ScanFindings]:
"""Per scan, open findings by actionable severity and whether the scan looked."""
if not scan_ids:
return {}
counts = (
await self.session.execute(
select(Vulnerability.scan_id, Vulnerability.severity, func.count())
.where(
Vulnerability.scan_id.in_(scan_ids),
Vulnerability.severity.in_(ACTIONABLE_SEVERITIES),
not_(vuln_suppressed(QueryScope(tuple(scan_ids)))),
)
.group_by(Vulnerability.scan_id, Vulnerability.severity)
)
).all()
wrote = set(
(
await self.session.execute(
select(Vulnerability.scan_id)
.where(Vulnerability.scan_id.in_(scan_ids))
.distinct()
)
).scalars()
)
ran = set(
(
await self.session.execute(
select(ScanActivity.scan_id)
.where(
ScanActivity.scan_id.in_(scan_ids),
ScanActivity.status.in_(_RAN),
ScanActivity.name.in_(
covering_stages()[SurfaceDimension.VULNERABILITIES.value]
),
)
.distinct()
)
).scalars()
)
out = {
sid: ScanFindings(covered=sid in wrote or sid in ran) for sid in scan_ids
}
for sid, severity, n in counts:
setattr(out[sid], severity, int(n))
return out
async def attach_target_runs(
self, items: list[ScanRead], include_focused: bool = False
) -> None:
"""Per row, how many runs its target holds."""
target_ids = list({i.target_id for i in items})
if not target_ids:
return
conds = [Scan.target_id.in_(target_ids)]
if not include_focused:
conds.append(census_only())
rows = (
await self.session.execute(
select(Scan.target_id, func.count())
.where(*conds)
.group_by(Scan.target_id)
)
).all()
runs = dict(rows)
for i in items:
i.target_runs = int(runs.get(i.target_id, 0))
async def latest_for_targets(
self, project_id: UUID, target_ids: list[UUID]
) -> list[ScanRead]:
"""The newest census run of each target, with the fields the history rows show."""
if not target_ids:
return []
started = func.coalesce(Scan.started_at, Scan.created_at)
ranked = (
select(
Scan.id.label("id"),
func.row_number()
.over(partition_by=Scan.target_id, order_by=started.desc())
.label("rn"),
)
.where(
Scan.project_id == project_id,
Scan.target_id.in_(target_ids),
census_only(),
)
.subquery()
)
rows = (
(
await self.session.execute(
select(Scan).where(
Scan.id.in_(select(ranked.c.id).where(ranked.c.rn == 1))
)
)
)
.scalars()
.all()
)
items = [self.to_read(s) for s in rows]
await self.attach_deltas(items)
await self.attach_target_runs(items)
await self._attach_covering_findings(project_id, items)
return items
async def _attach_covering_findings(
self, project_id: UUID, items: list[ScanRead]
) -> None:
"""Replace each row's findings with its target's vulnerability covering scan."""
if not items:
return
covering = dict(
(
await self.session.execute(
select(Scan.target_id, Scan.id)
.where(
Scan.project_id == project_id,
Scan.target_id.in_([i.target_id for i in items]),
census_only(),
covers(Vulnerability, SurfaceDimension.VULNERABILITIES.value),
)
.distinct(Scan.target_id)
.order_by(
Scan.target_id,
func.coalesce(Scan.started_at, Scan.created_at).desc(),
)
)
).all()
)
moved = [i for i in items if covering.get(i.target_id) not in (None, i.id)]
counts = await self.finding_counts([covering[i.target_id] for i in moved])
for i in moved:
sid = covering[i.target_id]
i.findings = counts.get(sid, ScanFindings()).model_copy(
update={"scan_id": sid}
)
async def finding_trends(
self, project_id: UUID, target_ids: list[UUID], limit: int = TREND_POINTS
) -> list[ScanTargetTrend]:
"""Per target, open findings of its last completed census runs, oldest first."""
if not target_ids:
return []
started = func.coalesce(Scan.started_at, Scan.created_at)
rn = (
func.row_number()
.over(partition_by=Scan.target_id, order_by=started.desc())
.label("rn")
)
ranked = (
select(
Scan.id.label("id"),
Scan.target_id.label("target_id"),
started.label("at"),
rn,
)
.where(
Scan.project_id == project_id,
Scan.target_id.in_(target_ids),
Scan.status == ScanStatus.COMPLETED.value,
census_only(),
)
.subquery()
)
runs = (
await self.session.execute(
select(ranked.c.id, ranked.c.target_id, ranked.c.at)
.where(ranked.c.rn <= limit)
.order_by(ranked.c.target_id, ranked.c.at.asc())
)
).all()
counts = await self.finding_counts([r.id for r in runs])
out: dict[UUID, ScanTargetTrend] = {
tid: ScanTargetTrend(target_id=tid) for tid in target_ids
}
for r in runs:
c = counts.get(r.id) or ScanFindings()
out[r.target_id].points.append(
ScanTrendPoint(
scan_id=r.id,
started_at=r.at,
critical=c.critical,
high=c.high,
medium=c.medium,
)
)
return list(out.values())
async def daily(
self,
project_id: UUID,
days: int,
target_id: UUID | None = None,
include_focused: bool = False,
) -> list[ScanDay]:
"""Runs started per UTC day, and how many found open findings of each severity."""
days = max(1, min(days, MAX_DAILY_WINDOW))
today = utc_now().replace(hour=0, minute=0, second=0, microsecond=0)
since = today - timedelta(days=days - 1)
started = func.coalesce(Scan.started_at, Scan.created_at)
conds = [Scan.project_id == project_id, started >= since]
if target_id is not None:
conds.append(Scan.target_id == target_id)
if not include_focused:
conds.append(census_only())
runs = (
await self.session.execute(
select(Scan.id, Scan.status, started.label("at")).where(*conds)
)
).all()
counts = await self.finding_counts([r.id for r in runs])
out = {
(since + timedelta(days=n)).date(): ScanDay(day=since + timedelta(days=n))
for n in range(days)
}
for r in runs:
day = out.get(r.at.astimezone(since.tzinfo).date())
if day is None:
continue
day.runs += 1
if r.status == ScanStatus.FAILED.value:
day.failed += 1
c = counts.get(r.id)
if c is None:
continue
for severity in ACTIONABLE_SEVERITIES:
if getattr(c, severity):
setattr(day, severity, getattr(day, severity) + 1)
return list(out.values())
async def recheck_tallies(self, scan_ids: list[UUID]) -> dict[UUID, RecheckTally]:
"""Per focused run, the assets it rechecked and which fields moved."""
if not scan_ids:
return {}
rows = (
await self.session.execute(
select(
AssetRecheck.scan_id,
func.count(),
func.count().filter(AssetRecheck.changed.is_(True)),
)
.where(AssetRecheck.scan_id.in_(scan_ids))
.group_by(AssetRecheck.scan_id)
)
).all()
tallies = {
scan_id: RecheckTally(assets=assets, changed=changed)
for scan_id, assets, changed in rows
}
for scan_id, fields in (await self._recheck_fields(scan_ids)).items():
if scan_id in tallies:
tallies[scan_id].fields = fields
return tallies
async def _recheck_fields(
self, scan_ids: list[UUID]
) -> dict[UUID, list[RecheckFieldCount]]:
"""Fold every asset's change list into one count per field and direction."""
change = (
func.jsonb_array_elements(cast(AssetRecheck.changes, JSONB))
.table_valued(column("value", JSONB))
.lateral()
)
value = change.c.value
rows = (
await self.session.execute(
select(
AssetRecheck.scan_id,
value["field"].astext.label("field"),
value["label"].astext.label("label"),
value["tone"].astext.label("tone"),
func.count(),
)
.select_from(AssetRecheck)
.join(change, true())
.where(AssetRecheck.scan_id.in_(scan_ids))
.group_by(AssetRecheck.scan_id, "field", "label", "tone")
)
).all()
out: dict[UUID, dict[str, RecheckFieldCount]] = {}
for scan_id, field, label, tone, count in rows:
if not field:
continue
by_field = out.setdefault(scan_id, {})
entry = by_field.setdefault(
field,
RecheckFieldCount(
field=field,
label=label or field,
dimension=change_dimension(field),
),
)
if tone == Tone.DOWN.value:
entry.down += count
else:
entry.up += count
return {
scan_id: sorted(
by_field.values(), key=lambda f: (-(f.up + f.down), f.label)
)
for scan_id, by_field in out.items()
}
async def rescan_summaries(
self, parent_ids: list[UUID]
) -> dict[UUID, RescanSummary]:
if not parent_ids:
return {}
rows = (
await self.session.execute(
select(Scan.parent_scan_id, Scan.status, func.count())
.where(Scan.parent_scan_id.in_(parent_ids))
.group_by(Scan.parent_scan_id, Scan.status)
)
).all()
out: dict[UUID, RescanSummary] = {}
for parent_id, status_value, count in rows:
summary = out.setdefault(parent_id, RescanSummary(total=0))
summary.total += count
if status_value in SCAN_LIVE_STATUSES:
summary.running += count
elif status_value == ScanStatus.FAILED.value:
summary.failed += count
return out
async def _get_scan(self, id: UUID, project_id: UUID, lock: bool = False) -> Scan:
statement = select(Scan).where(Scan.id == id, Scan.project_id == project_id)
if lock:
statement = statement.with_for_update()
result = await self.session.execute(statement)
scan = result.scalar_one_or_none()
if not scan:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scan not found"
)
return scan
async def cancel(self, id: UUID, project_id: UUID) -> ScanRead:
scan = await self._get_scan(id, project_id, lock=True)
if scan.status in SCAN_OPEN_STATUSES:
held = scan.status == ScanStatus.RUNNING.value
scan.status = ScanStatus.CANCELLED.value
scan.completed_at = utc_now()
fold_pause(scan, scan.completed_at)
scan.error = "Cancelled by user."
await self.session.commit()
now = utc_now()
for model in (ScanActivity, ScanCommand):
await self.session.execute(
update(model)
.where(
model.scan_id == scan.id,
model.status.in_(_UNSETTLED_ACTIVITY_STATUSES),
)
.values(status=ScanActivityStatus.ABORTED.value, completed_at=now)
)
await self.session.commit()
try:
await asyncio.to_thread(dispatch_scan_finalize, str(scan.id))
except Exception:
logger.warning("cancel finalize dispatch failed", exc_info=True)
if held:
await self._admit_waiting()
await self._announce_cancelled(scan)
await self.session.refresh(scan)
return self._to_read(scan)
async def cancel_all(
self, project_id: UUID, target_id: UUID | None = None
) -> ScanCancelAll:
conds = [Scan.project_id == project_id, Scan.status.in_(SCAN_OPEN_STATUSES)]
if target_id is not None:
conds.append(Scan.target_id == target_id)
ids = (
(await self.session.execute(select(Scan.id).where(*conds))).scalars().all()
)
cancelled = 0
for scan_id in ids:
read = await self.cancel(id=scan_id, project_id=project_id)
if read.status == ScanStatus.CANCELLED.value:
cancelled += 1
return ScanCancelAll(cancelled=cancelled)
async def pause(self, id: UUID, project_id: UUID) -> ScanRead:
"""Stop the run where it stands."""
scan = await self._get_scan(id, project_id, lock=True)
if scan.status not in SCAN_LIVE_STATUSES:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="The scan is not running."
)
if (scan.execution_config or {}).get(WATCH_HOST_KEY):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A watch probe cannot be paused. Cancel it to stop the run.",
)
now = utc_now()
scan.status = ScanStatus.PAUSED.value
scan.paused_at = now
stopped = (
await self.session.execute(
update(ScanActivity)
.where(
ScanActivity.scan_id == scan.id,
ScanActivity.status == ScanActivityStatus.RUNNING.value,
)
.values(status=ScanActivityStatus.PAUSED.value)
)
).rowcount
await self.session.execute(
update(ScanCommand)
.where(
ScanCommand.scan_id == scan.id,
ScanCommand.status == ScanActivityStatus.RUNNING.value,
)
.values(status=ScanActivityStatus.ABORTED.value, completed_at=now)
)
await self._log_paused(scan, stopped)
await self.session.commit()
revoke_scan_tasks(scan.celery_task_ids or [])
await self._admit_waiting()
self._announce_paused(scan, stopped)
await self.session.refresh(scan)
return self._to_read(scan)
async def resume(self, id: UUID, project_id: UUID) -> ScanRead:
"""Move the run back to RUNNING here, so the answer is true before the worker acts."""
scan = await self._get_scan(id, project_id, lock=True)
if scan.status != ScanStatus.PAUSED.value:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="The scan is not paused."
)
now = utc_now()
paused_at = scan.paused_at
paused_seconds = scan.paused_seconds
left = await self._stages_left(scan)
fold_pause(scan, now)
scan.status = ScanStatus.RUNNING.value
scan.error = None
scan.run_epoch = epoch = (scan.run_epoch or 0) + 1
if scan.started_at is None:
scan.started_at = now
await self._log_resumed(scan, left)
await self.session.commit()
try:
dispatch_scan_resume(str(scan.id), epoch)
except Exception:
logger.warning("scan resume dispatch failed", exc_info=True)
scan.status = ScanStatus.PAUSED.value
scan.paused_at = paused_at or now
scan.paused_seconds = paused_seconds
await self.session.commit()
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="The scan was not resumed. Check that the worker service is running.",
) from None
self._announce_resumed(scan, left)
await self.session.refresh(scan)
return self._to_read(scan)
async def _stages_left(self, scan: Scan) -> int:
rows = (
(
await self.session.execute(
select(ScanActivity).where(ScanActivity.scan_id == scan.id)
)
)
.scalars()
.all()
)
return resume_point(list(rows))[2]
async def _log_resumed(self, scan: Scan, left: int) -> None:
target_value = (scan.execution_config or {}).get("target_value", "")
await ActivityLogService(self.session).log_async(
event=ActivityEvent.SCAN_RESUMED,
title=f"Scan resumed · {target_value}",
description=f"{left} {'stage' if left == 1 else 'stages'} remaining",
level=ActivityLevel.INFO,
project_id=scan.project_id,
target_id=scan.target_id,
scan_id=scan.id,
target_value=target_value,
)
def _announce_resumed(self, scan: Scan, left: int) -> None:
try:
ScanEventPublisher(
BaseAppSettings().redis_url,
scan_id=str(scan.id),
project_id=str(scan.project_id),
).scan_resumed(status=ScanStatus.RUNNING.value, stages_left=left)
except Exception:
logger.debug("resume event emit failed", exc_info=True)
async def _log_paused(self, scan: Scan, stopped: int) -> None:
target_value = (scan.execution_config or {}).get("target_value", "")
await ActivityLogService(self.session).log_async(
event=ActivityEvent.SCAN_PAUSED,
title=f"Scan paused · {target_value}",
description=f"{stopped} stage{'s' if stopped != 1 else ''} stopped"
if stopped
else None,
level=ActivityLevel.INFO,
project_id=scan.project_id,
target_id=scan.target_id,
scan_id=scan.id,
target_value=target_value,
)
def _announce_paused(self, scan: Scan, stopped: int) -> None:
try:
ScanEventPublisher(
BaseAppSettings().redis_url,
scan_id=str(scan.id),
project_id=str(scan.project_id),
).scan_paused(status=ScanStatus.PAUSED.value, stages_stopped=stopped)
except Exception:
logger.debug("pause event emit failed", exc_info=True)
async def _announce_cancelled(self, scan: Scan) -> None:
try:
ScanEventPublisher(
BaseAppSettings().redis_url,
scan_id=str(scan.id),
project_id=str(scan.project_id),
).scan_cancelled(status=ScanStatus.CANCELLED.value)
except Exception:
logger.debug("cancel event emit failed", exc_info=True)
async def list_activities(
self, scan_id: UUID, project_id: UUID
) -> list[ScanActivityRead]:
await self._get_scan(scan_id, project_id)
rows = (
(
await self.session.execute(
select(ScanActivity)
.where(ScanActivity.scan_id == scan_id)
.order_by(ScanActivity.created_at.asc())
)
)
.scalars()
.all()
)
counts = dict(
(
await self.session.execute(
select(ScanCommand.activity_id, func.count())
.where(ScanCommand.scan_id == scan_id)
.group_by(ScanCommand.activity_id)
)
).all()
)
out: list[ScanActivityRead] = []
for a in rows:
read = ScanActivityRead.model_validate(a, from_attributes=True)
read.command_count = counts.get(a.id, 0)
read.duration_seconds = (
round((a.completed_at - a.started_at).total_seconds(), 1)
if a.started_at and a.completed_at
else None
)
out.append(read)
return out
async def list_commands(
self,
scan_id: UUID,
project_id: UUID,
activity_id: UUID | None = None,
) -> list[ScanCommandRead]:
await self._get_scan(scan_id, project_id)
query = select(ScanCommand).where(ScanCommand.scan_id == scan_id)
if activity_id is not None:
query = query.where(ScanCommand.activity_id == activity_id)
rows = (
(await self.session.execute(query.order_by(ScanCommand.started_at.asc())))
.scalars()
.all()
)
return [_command_read(c) for c in rows]
async def get_command(
self, scan_id: UUID, command_id: UUID, project_id: UUID
) -> ScanCommandDetail:
await self._get_scan(scan_id, project_id)
cmd = (
await self.session.execute(
select(ScanCommand).where(
ScanCommand.id == command_id, ScanCommand.scan_id == scan_id
)
)
).scalar_one_or_none()
if cmd is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Command not found"
)
detail = ScanCommandDetail.model_validate(cmd, from_attributes=True)
detail.command = redact_command(detail.command)
if detail.output:
detail.output = redact_command(detail.output)
return detail
async def delete(self, id: UUID, project_id: UUID) -> None:
scan = await self._get_scan(id, project_id)
held = scan.status == ScanStatus.RUNNING.value
if scan.status in SCAN_OPEN_STATUSES:
revoke_scan_tasks(scan.celery_task_ids or [])
await self.session.delete(scan)
await self.session.commit()
if held:
await self._admit_waiting()
async def _admit_waiting(self) -> None:
try:
await asyncio.to_thread(dispatch_scan_admission)
except Exception:
logger.warning("scan admission dispatch failed", exc_info=True)
def _to_read(self, scan: Scan) -> ScanRead:
cfg = copy.deepcopy(scan.execution_config or {})
cfg.pop("_auth_header_names", None)
auth = cfg.pop("_auth", None) or {"auth_type": "none"}
masked = _mask_config_headers(cfg)
resolved = _resolved_config(scan.id, masked)
return ScanRead(
id=scan.id,
project_id=scan.project_id,
target_id=scan.target_id,
engine_id=scan.engine_id,
engine_name=scan.engine_name,
context_id=scan.context_id,
context_name=scan.context_name,
schedule_id=scan.schedule_id,
schedule_type=scan.schedule_type,
scope=scan.scope,
parent_scan_id=scan.parent_scan_id,
run_group_id=scan.run_group_id,
seed_count=len((scan.execution_config or {}).get("seed_assets") or []),
execution_config=resolved,
auth_summary=_auth_summary(auth, list(masked.get("headers", {}).keys())),
status=scan.status,
subdomains_found=scan.subdomains_found,
ips_found=scan.ips_found,
open_ports_found=scan.open_ports_found,
http_assets_found=scan.http_assets_found,
vulnerabilities_found=scan.vulnerabilities_found,
endpoints_found=scan.endpoints_found,
error=scan.error,
created_by=scan.created_by,
created_at=scan.created_at,
started_at=scan.started_at,
completed_at=scan.completed_at,
paused_at=scan.paused_at,
paused_seconds=scan.paused_seconds or 0.0,
duration_seconds=_scan_duration(scan),
)