Files
rengine/api/app/services/scan.py
T
Yogesh OjhaandClaude Opus 5 ed5f8f3f16 feat(notifications): a notification is an interrupt, the activity log is the ledger
The in-app feed was a second copy of the activity log with a red badge on it.
A scan published up to six notifications (started, completed, new subdomains,
new services, new vulnerabilities, coverage), every one of them already an
activity_logs row and every one of them toasted for five seconds. A nightly
twenty-target schedule left forty to a hundred and twenty unread rows.

If it does not want a human to do something, it is not a notification.

Deleted scan_started, scan_completed and scan_cancelled (both the API-side echo
of the user's own Cancel click and the finalize one). whois/ripestat
enrichment_complete became enrichment_incomplete and return None unless
something actually failed.

One digest replaces the five scan templates. scan_digest(...) -> dict | None
takes a ScanDeltas and derives its severity, title, type and deep-link tab from
the worst thing inside it, and returns None when nothing changed, so a quiet
scheduled run is silent. Severity now encodes risk rather than process:
scan_failed is warning, not error, because the bell takes its colour from the
highest unread severity and one flaky run should not read like a KEV.

Toasts are interrupts only: warning and error toast, success and info land
silently in the bell. A toast with a url finally gets a View action; it used to
require an action_label no template ever set.

notifications.project_id scopes the bell to the active project (nullable and
indexed, no FK so a transient row can never block a project delete). The list,
stats, read-all and clear-all endpoints take ?project_id=, and the store drops
SSE events from other projects.

app.tasks.notifications.cleanup runs on beat every six hours. expires_at had
been decorative since nothing ever called cleanup_expired, and the inbox modal
fetched stats.total rows in a single request.

Three bugs this uncovered:

_count_new_subdomains had no baseline gate while services and vulnerabilities
did, so a target's first scan announced its whole inventory as new: the first
gov.np run would have reported 24022 new subdomains. Baselines are now per
dimension, and a run with none reports the census instead.

The delta counts ignored census_only, so a focused rescan both counted as a
baseline and had its own rows announced. Every comparison now joins
scans bs ... scope = 'full' AND bs.started_at < :started.

Measuring the deltas took 16.8s on a 13,528-host scan: the scan-level baseline
was a correlated EXISTS re-evaluated once per row and could not use
ix_subdomains_target_name_discovered, having no name predicate. Hoisting it to
one scan-level check and rewriting the per-row test as a hash anti-join over a
WITH seen AS (SELECT DISTINCT ...) CTE gives the identical answer in 52ms,
verified against a plain EXCEPT query.

Not done, deliberately: is_read is still one global flag, so on a shared
instance one analyst's Mark all as read still applies to everyone; that needs a
notification_reads table. And notifier.wants() still governs external channels
only, the bell having no preference layer, because volume reduction was the fix.

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

1248 lines
45 KiB
Python

import asyncio
import copy
import logging
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, exists, func, nullslast, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
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.rescan import ASSET_SEED_STAGE, rescan_label
from shared.enums.api_key import APIProvider
from shared.enums.scan import SCAN_LIVE_STATUSES, ScanActivityStatus, ScanStatus
from shared.models.api_key import APIKey
from shared.models.scan import (
SCAN_STATUSES,
Scan,
ScanBatchCreate,
ScanChanges,
ScanCreate,
ScanDailyCount,
ScanExportRow,
ScanFacet,
ScanRead,
ScanStats,
ScanStatusCounts,
)
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_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.services.celery_dispatch import 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,
_check_baseline_deferred,
mask_proxy_url,
merge_engine_context,
redact_command,
)
from shared.services.scan_scope import census_only
from shared.utils.datetime import utc_now
from shared.utils.validation import validate_target
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),
"30d": timedelta(days=30),
}
MAX_SCAN_EXPORT = 50000
_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
def _scan_duration(scan: Scan) -> float | None:
if scan.started_at is None:
return None
end = scan.completed_at or (
utc_now() if scan.status == ScanStatus.RUNNING.value else None
)
if end is None:
return None
return round((end - scan.started_at).total_seconds(), 1)
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"
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)
_check_baseline_deferred(
context.compare_baseline_scan_id, context.scan_only_new_assets
)
proxy_url = None
if context is not None and context.proxy_id is not None:
proxy_url = await ProxyService(self.session).resolve_proxy_url(
context.proxy_id
)
return engine, context, proxy_url
@staticmethod
def _resolve_for(
engine,
context,
target: Target,
proxy_url: str | None,
data: ScanCreate | ScanBatchCreate,
):
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:
resolved.seed_assets = [seed.model_dump() for seed in seeds]
resolved.stages[ASSET_SEED_STAGE] = {
**(resolved.stages.get(ASSET_SEED_STAGE) or {}),
"enabled": True,
}
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; transient when previewing."""
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=f"Invalid 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)
engine, resolved = self._resolve_for(engine, context, target, proxy_url, data)
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 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)
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
rates = resolved.per_tool_rate_limits
rate_summary = ", ".join(f"{tool} {rates[tool]}/s" for tool in sorted(rates))
if resolved.global_rate_limit_ceiling is not None:
rate_summary += f" (ceiling {resolved.global_rate_limit_ceiling}/s)"
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_name = None
if context is not None and context.proxy_id is not None:
try:
proxy_name = (
await ProxyService(self.session).get(context.proxy_id)
).name
except HTTPException:
proxy_name = 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,
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
)
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,
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)
self._dispatch_scan(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)
scans: list[Scan] = []
for target in targets:
run_engine, resolved = self._resolve_for(
engine, context, target, proxy_url, data
)
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))
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 = "Could not be queued for execution."
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
)
if sort_by == "status":
return _STATUS_RANK
if sort_by == "subdomains":
return Scan.subdomains_found
if sort_by == "vulnerabilities":
return Scan.vulnerabilities_found
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,
) -> list:
conds: list = []
if not include_focused:
conds.append(census_only(m))
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,
) -> Select:
query = select(Scan).where(
Scan.project_id == project_id,
*self._filter_conditions(
Scan, statuses, engines, contexts, time_range, include_focused
),
)
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),
)
)
expr = self._sort_expr(sort_by)
ordering = expr.asc() if sort_dir == "asc" else expr.desc()
# Only `duration` can be NULL (un-started scans); nulls-last on the others
# would break the ix_scans_*_started index match (default DESC is nulls-first).
if sort_by == "duration":
ordering = nullslast(ordering)
return query.order_by(ordering, Scan.created_at.desc())
def build_target_groups_query(
self,
project_id: UUID,
statuses: list[str] | None = None,
engines: list[str] | None = None,
contexts: list[str] | None = None,
search: str | None = None,
time_range: str | None = None,
) -> Select:
last_t = func.coalesce(Scan.started_at, Scan.created_at)
inner = aliased(Scan)
last_status = (
select(inner.status)
.where(
inner.target_id == Scan.target_id,
inner.project_id == project_id,
*self._filter_conditions(
inner, statuses, engines, contexts, time_range
),
)
.order_by(func.coalesce(inner.started_at, inner.created_at).desc())
.limit(1)
.correlate(Scan)
.scalar_subquery()
)
live = case(
(
Scan.status.in_([ScanStatus.RUNNING.value, ScanStatus.PENDING.value]),
1,
),
else_=0,
)
query = (
select(
Scan.target_id.label("target_id"),
Target.target_value.label("target_value"),
Target.target_type.label("target_type"),
func.count().label("scan_count"),
func.max(last_t).label("last_scan_at"),
func.sum(live).label("running"),
last_status.label("last_status"),
)
.join(Target, Scan.target_id == Target.id)
.where(
Scan.project_id == project_id,
*self._filter_conditions(Scan, statuses, engines, contexts, time_range),
)
.group_by(Scan.target_id, Target.target_value, Target.target_type)
)
if search and search.strip():
term = f"%{search.strip().lower()}%"
query = query.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),
)
)
return query.order_by(func.max(last_t).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,
) -> 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,
).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], target_ids: list[UUID]
) -> dict[UUID, int]:
"""Per scan, how many subdomain names it was the FIRST to discover for its target."""
if not scan_ids or not target_ids:
return {}
rn = (
func.row_number()
.over(
partition_by=[Subdomain.target_id, Subdomain.name],
order_by=[Subdomain.discovered_at.asc(), Subdomain.scan_id.asc()],
)
.label("rn")
)
firsts = (
select(Subdomain.scan_id.label("scan_id"), rn)
.where(Subdomain.target_id.in_(target_ids))
.subquery()
)
rows = (
await self.session.execute(
select(firsts.c.scan_id, func.count())
.where(firsts.c.rn == 1, firsts.c.scan_id.in_(scan_ids))
.group_by(firsts.c.scan_id)
)
).all()
return dict(rows)
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,
)
.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 (baseline run — no prior to diff against)."""
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))
.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 {}
ordering = func.coalesce(Scan.started_at, Scan.created_at)
prev_id = (
func.lag(Scan.id)
.over(partition_by=Scan.target_id, order_by=ordering.asc())
.label("prev_id")
)
completed = (
select(Scan.id.label("id"), prev_id)
.where(
Scan.target_id.in_(target_ids),
Scan.status == ScanStatus.COMPLETED.value,
)
.subquery()
)
pairs = (
select(completed.c.id, completed.c.prev_id)
.where(completed.c.id.in_(scan_ids), completed.c.prev_id.is_not(None))
.subquery()
)
sp = aliased(Subdomain)
sc = aliased(Subdomain)
query = (
select(pairs.c.id, func.count())
.select_from(pairs)
.join(sp, sp.scan_id == pairs.c.prev_id)
.where(
~exists(select(1).where(sc.scan_id == pairs.c.id, sc.name == sp.name))
)
.group_by(pairs.c.id)
)
rows = (await self.session.execute(query)).all()
return dict(rows)
async def target_trends(
self, target_ids: list[UUID], limit: int = 12
) -> dict[UUID, list[int]]:
"""Per target, subdomains_found across its completed runs (oldest→newest, last `limit`)."""
if not target_ids:
return {}
ordering = func.coalesce(Scan.started_at, Scan.created_at)
rows = (
await self.session.execute(
select(Scan.target_id, Scan.subdomains_found)
.where(
Scan.target_id.in_(target_ids),
Scan.status == ScanStatus.COMPLETED.value,
)
.order_by(Scan.target_id, ordering.asc())
)
).all()
out: dict[UUID, list[int]] = {}
for tid, n in rows:
out.setdefault(tid, []).append(n)
return {tid: vals[-limit:] for tid, vals in out.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 changes(
self, project_id: UUID, window: str, target_id: UUID | None = None
) -> ScanChanges:
cutoff = utc_now() - _WINDOW_DELTAS.get(window, timedelta(days=7))
sub_conds = [Subdomain.project_id == project_id]
if target_id is not None:
sub_conds.append(Subdomain.target_id == target_id)
firsts = (
select(
Subdomain.target_id.label("target_id"),
func.min(Subdomain.discovered_at).label("first_seen"),
)
.where(*sub_conds)
.group_by(Subdomain.target_id, Subdomain.name)
.subquery()
)
new_subdomains = (
await self.session.execute(
select(func.count()).where(firsts.c.first_seen >= cutoff)
)
).scalar_one()
targets_changed = (
await self.session.execute(
select(func.count(func.distinct(firsts.c.target_id))).where(
firsts.c.first_seen >= cutoff
)
)
).scalar_one()
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))
).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
)
start = (utc_now() - timedelta(days=29)).date()
status_day_rows = (
await self.session.execute(
select(func.date(Scan.created_at), Scan.status, func.count())
.where(*conds, func.date(Scan.created_at) >= start)
.group_by(func.date(Scan.created_at), Scan.status)
)
).all()
by_day_status: dict[str, dict[str, int]] = {}
for d, st, n in status_day_rows:
by_day_status.setdefault(str(d), {})[st] = n
sub_conds = [Subdomain.project_id == project_id]
if target_id is not None:
sub_conds.append(Subdomain.target_id == target_id)
firsts = (
select(func.min(Subdomain.discovered_at).label("first_seen"))
.where(*sub_conds)
.group_by(Subdomain.target_id, Subdomain.name)
.subquery()
)
first_day = func.date(firsts.c.first_seen)
new_day_rows = (
await self.session.execute(
select(first_day, func.count())
.where(first_day >= start)
.group_by(first_day)
)
).all()
new_by_day = {str(d): n for d, n in new_day_rows}
daily = []
for i in range(30):
d = (start + timedelta(days=i)).isoformat()
s = by_day_status.get(d, {})
daily.append(
ScanDailyCount(
date=d,
count=sum(s.values()),
completed=s.get(ScanStatus.COMPLETED.value, 0),
failed=s.get(ScanStatus.FAILED.value, 0),
cancelled=s.get(ScanStatus.CANCELLED.value, 0),
running=s.get(ScanStatus.RUNNING.value, 0),
pending=s.get(ScanStatus.PENDING.value, 0),
new_subdomains=new_by_day.get(d, 0),
)
)
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,
daily=daily,
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, target_ids)
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)
for i in items:
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
async def _get_scan(self, id: UUID, project_id: UUID) -> Scan:
result = await self.session.execute(
select(Scan).where(Scan.id == id, Scan.project_id == project_id)
)
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)
if scan.status in SCAN_LIVE_STATUSES:
scan.status = ScanStatus.CANCELLED.value
scan.completed_at = utc_now()
scan.error = "Cancelled by user."
await self.session.commit()
revoke_scan_tasks(scan.celery_task_ids or [])
now = utc_now()
for model in (ScanActivity, ScanCommand):
await self.session.execute(
update(model)
.where(
model.scan_id == scan.id,
model.status == ScanActivityStatus.RUNNING.value,
)
.values(status=ScanActivityStatus.ABORTED.value, completed_at=now)
)
await self.session.commit()
await self._announce_cancelled(scan)
await self.session.refresh(scan)
return self._to_read(scan)
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)
if scan.status in SCAN_LIVE_STATUSES:
revoke_scan_tasks(scan.celery_task_ids or [])
await self.session.delete(scan)
await self.session.commit()
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,
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,
duration_seconds=_scan_duration(scan),
)