Files
rengine/api/app/services/scan_schedule.py
T
Yogesh OjhaandClaude Opus 5 0b853e04b6 chore: production-readiness review across the tree
A full static review of the 3.0 tree, run as two fan-out passes with every
finding verified against the running stack rather than taken on inspection.

Correctness and security
- instance settings encrypted the mask over the stored AI key: `!= MASK` never
  matched `••••••••abcd`, so saving settings destroyed the key and `test_ai`
  posted the mask as a bearer token
- the MFA token was replayable for its full five-minute life; the gate now
  checks revocation and consumes the token after a successful exchange
- logout revoked nothing for a bearer client while answering "Logged out"
- `POST /api-keys/{id}/test` returned the plaintext key inside httpx's error
  string, which embeds the whole URL
- an interest rule could be edited or deleted through another project's id
- redis ran the celery broker under `allkeys-lru`, free to evict a queued task
  or a chord counter; now `noeviction`
- nuclei's stored command carried the OAST token and every auth header
- a finalize step could poison the session and leave a scan RUNNING for ever
- `is:issue` raised TypeError on every search
- offset paging repeated and skipped rows in four dimensions
- a cancelled run kept a stale `scans.*_found` rollup
- every PDF report raised `KeyError: 'software'`
- `version_key` truncated a wide numeric run and inverted release ordering
- psycopg raises a NUL as a bare ValueError, above StatementError, so every
  savepoint guard was blind to the case it was written for

Performance
- endpoints.host had no trigram index: 35.9ms seq scan -> 0.389ms
- free text compiled to one OR, not the prescribed UNION ALL: 132ms -> 41ms
- `/http-assets/summary` and `/ips/summary` materialised whole tables
- the cross-link `ip` kind ran an estate query per page and drew nothing

Frontend
- `$effect` loaders that read the state they write, an unbounded request loop
  on a failing endpoint
- error states that rendered as empty states
- 2FA backup codes were unusable while the copy said they worked
- a "Scan history" dialog that always reported none
- ~400 duplicated lines across five filter bars, now one component

Build and tooling
- every scanner installed at @latest; all 13 pinned to the measured versions
- CI built `web/`, gone since 2.x, with tags that cannot be valid
- the prettier pre-commit hook discarded its filenames and always passed
- the test suite destroyed itself when run twice

Also: subdomains from public code, via a pinned github-subdomains source and a
GitHub API key that subfinder receives too; sublist3r and oneforall removed
after measuring one unique host in 89 across four targets.

Verified: 853 tests, ruff clean over eleven packages, svelte-check 0 errors,
the count-is-a-promise gate at 131 checks and 0 mismatches, and 143 GET routes
with no 5xx. Four migrations, each with an exercised downgrade.

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

366 lines
14 KiB
Python

from datetime import UTC, datetime
from uuid import UUID
from zoneinfo import ZoneInfo
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.scan import ScanService
from shared.definitions.schedule_constants import MAX_INTERVAL, TIME_RE
from shared.enums.scan_schedule import (
INTERVAL_UNITS,
SCHEDULE_TYPES,
ScheduleStatus,
ScheduleType,
)
from shared.models.instance_settings import InstanceSettings
from shared.models.scan import ScanCreate, ScanRead
from shared.models.scan_context import ScanContext
from shared.models.scan_engine import ScanEngine
from shared.models.scan_schedule import (
ScanSchedule,
ScanScheduleCreate,
ScanScheduleRead,
ScanScheduleUpdate,
ScheduleTargetRef,
)
from shared.models.target import Target
from shared.services.schedule_timing import compute_next_run_at, describe_schedule
from shared.utils.datetime import utc_now
from shared.utils.uuid import uuid_list
def _bad(detail: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
def _validate_tz(tz: str) -> None:
try:
ZoneInfo(tz)
except Exception as exc:
msg = f"Instance timezone '{tz}' is invalid. Set a valid timezone in Settings."
raise _bad(msg) from exc
class ScanScheduleService:
def __init__(self, session: AsyncSession):
self.session = session
async def _instance_timezone(self) -> str:
row = (
await self.session.execute(select(InstanceSettings).limit(1))
).scalar_one_or_none()
return row.timezone if row and row.timezone else "UTC"
async def _get(
self, id: UUID, project_id: UUID, *, for_update: bool = False
) -> ScanSchedule:
query = select(ScanSchedule).where(
ScanSchedule.id == id, ScanSchedule.project_id == project_id
)
if for_update:
query = query.with_for_update()
row = (await self.session.execute(query)).scalar_one_or_none()
if row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Schedule not found"
)
return row
async def _get_engine(self, engine_id: UUID, project_id: UUID) -> ScanEngine:
engine = (
await self.session.execute(
select(ScanEngine).where(
ScanEngine.id == engine_id, ScanEngine.project_id == project_id
)
)
).scalar_one_or_none()
if engine is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scan engine not found"
)
return engine
async def _get_context(
self, context_id: UUID | None, project_id: UUID
) -> ScanContext | None:
if context_id is None:
return None
ctx = (
await self.session.execute(
select(ScanContext).where(
ScanContext.id == context_id, ScanContext.project_id == project_id
)
)
).scalar_one_or_none()
if ctx is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Scan context not found"
)
return ctx
async def _targets(self, target_ids: list[UUID], project_id: UUID) -> list[Target]:
if not target_ids:
msg = "At least one target is required."
raise _bad(msg)
rows = (
(
await self.session.execute(
select(Target).where(
Target.id.in_(target_ids), Target.project_id == project_id
)
)
)
.scalars()
.all()
)
found = {t.id for t in rows}
missing = [str(t) for t in target_ids if t not in found]
if missing:
msg = f"Targets not found: {', '.join(missing)}"
raise _bad(msg)
return list(rows)
def _to_utc(self, dt: datetime, tz: str) -> datetime:
if dt.tzinfo is None:
return dt.replace(tzinfo=ZoneInfo(tz)).astimezone(UTC)
return dt.astimezone(UTC)
def _apply_timing(self, sched: ScanSchedule, data, tz: str) -> None:
stype = data.schedule_type
if stype not in SCHEDULE_TYPES:
msg = f"Invalid schedule type. One of {', '.join(SCHEDULE_TYPES)}."
raise _bad(msg)
sched.schedule_type = stype
sched.run_at = None
sched.interval_every = None
sched.interval_unit = None
sched.daily_at_time = None
sched.cron_expression = None
if stype == ScheduleType.ONE_OFF.value:
if data.run_at is None:
msg = "A one-off schedule needs a run time."
raise _bad(msg)
run_at = self._to_utc(data.run_at, tz)
if run_at <= utc_now():
msg = "The scheduled time must be in the future."
raise _bad(msg)
sched.run_at = run_at
elif stype == ScheduleType.INTERVAL.value:
if not data.interval_every or data.interval_every < 1:
msg = "Interval must be a positive number."
raise _bad(msg)
if data.interval_every > MAX_INTERVAL:
msg = f"Interval may not exceed {MAX_INTERVAL}."
raise _bad(msg)
if data.interval_unit not in INTERVAL_UNITS:
msg = f"Invalid unit. One of {', '.join(INTERVAL_UNITS)}."
raise _bad(msg)
sched.interval_every = data.interval_every
sched.interval_unit = data.interval_unit
elif stype == ScheduleType.DAILY_AT.value:
if not data.daily_at_time or not TIME_RE.match(data.daily_at_time):
msg = "Time must be HH:MM, 24-hour."
raise _bad(msg)
sched.daily_at_time = data.daily_at_time
elif stype == ScheduleType.CRON.value:
from croniter import croniter # noqa: PLC0415
if not data.cron_expression or not croniter.is_valid(data.cron_expression):
msg = "Invalid cron expression."
raise _bad(msg)
sched.cron_expression = data.cron_expression
async def _targets_by_ids(self, ids: list[UUID]) -> dict[UUID, Target]:
if not ids:
return {}
rows = (
(await self.session.execute(select(Target).where(Target.id.in_(ids))))
.scalars()
.all()
)
return {t.id: t for t in rows}
async def _read_one(self, sched: ScanSchedule) -> ScanScheduleRead:
targets = await self._targets_by_ids(uuid_list(sched.target_ids))
return self._to_read(sched, targets, await self._instance_timezone())
def _to_read(
self, sched: ScanSchedule, targets: dict[UUID, Target], current_tz: str
) -> ScanScheduleRead:
target_ids = uuid_list(sched.target_ids)
refs = [
ScheduleTargetRef(
id=tid,
target_value=targets[tid].target_value,
target_type=targets[tid].target_type.value,
)
for tid in target_ids
if tid in targets
]
return ScanScheduleRead(
id=sched.id,
project_id=sched.project_id,
name=sched.name,
target_ids=target_ids,
targets=refs,
engine_id=sched.engine_id,
engine_name=sched.engine_name,
context_id=sched.context_id,
context_name=sched.context_name,
intensity=sched.intensity,
schedule_type=sched.schedule_type,
run_at=sched.run_at,
interval_every=sched.interval_every,
interval_unit=sched.interval_unit,
daily_at_time=sched.daily_at_time,
cron_expression=sched.cron_expression,
timezone=sched.timezone,
status=sched.status,
next_run_at=sched.next_run_at,
last_run_at=sched.last_run_at,
last_error=sched.last_error,
total_run_count=sched.total_run_count,
cadence=describe_schedule(sched),
timezone_stale=sched.timezone != current_tz,
created_by=sched.created_by,
created_at=sched.created_at,
updated_at=sched.updated_at,
)
async def create(
self, data: ScanScheduleCreate, project_id: UUID, created_by: UUID
) -> ScanScheduleRead:
tz = await self._instance_timezone()
_validate_tz(tz)
engine = await self._get_engine(data.engine_id, project_id)
context = await self._get_context(data.context_id, project_id)
targets = await self._targets(data.target_ids, project_id)
sched = ScanSchedule(
project_id=project_id,
name=data.name.strip() or "Schedule",
target_ids=[str(t.id) for t in targets],
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,
intensity=data.intensity,
timezone=tz,
status=ScheduleStatus.ACTIVE.value,
created_by=created_by,
)
self._apply_timing(sched, data, tz)
sched.next_run_at = compute_next_run_at(sched)
self.session.add(sched)
await self.session.commit()
await self.session.refresh(sched)
return self._to_read(sched, {t.id: t for t in targets}, tz)
async def list_schedules(self, project_id: UUID) -> list[ScanScheduleRead]:
rows = (
(
await self.session.execute(
select(ScanSchedule)
.where(ScanSchedule.project_id == project_id)
.order_by(ScanSchedule.created_at.desc())
)
)
.scalars()
.all()
)
all_ids = [tid for s in rows for tid in uuid_list(s.target_ids)]
targets = await self._targets_by_ids(list(set(all_ids)))
current_tz = await self._instance_timezone()
return [self._to_read(s, targets, current_tz) for s in rows]
async def get(self, id: UUID, project_id: UUID) -> ScanScheduleRead:
sched = await self._get(id, project_id)
return await self._read_one(sched)
async def update(
self, id: UUID, project_id: UUID, data: ScanScheduleUpdate
) -> ScanScheduleRead:
sched = await self._get(id, project_id, for_update=True)
if data.name is not None:
sched.name = data.name.strip() or sched.name
if data.engine_id is not None:
engine = await self._get_engine(data.engine_id, project_id)
sched.engine_id = engine.id
sched.engine_name = engine.name
if data.context_id is not None or "context_id" in data.model_fields_set:
context = await self._get_context(data.context_id, project_id)
sched.context_id = context.id if context is not None else None
sched.context_name = context.name if context is not None else None
if data.target_ids is not None:
targets = await self._targets(data.target_ids, project_id)
sched.target_ids = [str(t.id) for t in targets]
if "intensity" in data.model_fields_set:
sched.intensity = data.intensity
if data.schedule_type is not None:
self._apply_timing(sched, data, sched.timezone)
if sched.status != ScheduleStatus.COMPLETED.value:
sched.next_run_at = compute_next_run_at(sched)
sched.updated_at = utc_now()
await self.session.commit()
await self.session.refresh(sched)
return await self._read_one(sched)
async def set_status(
self, id: UUID, project_id: UUID, new_status: ScheduleStatus
) -> ScanScheduleRead:
sched = await self._get(id, project_id, for_update=True)
sched.status = new_status.value
if new_status == ScheduleStatus.ACTIVE:
nxt = compute_next_run_at(sched)
if nxt is None and sched.schedule_type == ScheduleType.ONE_OFF.value:
sched.status = ScheduleStatus.COMPLETED.value
sched.next_run_at = nxt
sched.updated_at = utc_now()
await self.session.commit()
await self.session.refresh(sched)
return await self._read_one(sched)
async def run_now(
self, id: UUID, project_id: UUID, created_by: UUID
) -> list[ScanRead]:
sched = await self._get(id, project_id, for_update=True)
scan_service = ScanService(self.session)
spawned: list[ScanRead] = []
errors: list[str] = []
for t in uuid_list(sched.target_ids):
try:
scan = await scan_service.create(
ScanCreate(
engine_id=sched.engine_id,
context_id=sched.context_id,
target_id=t,
intensity=sched.intensity,
),
project_id=project_id,
created_by=created_by,
schedule_id=sched.id,
schedule_type=sched.schedule_type,
)
spawned.append(scan)
except HTTPException as exc:
errors.append(f"{t}: {exc.detail}")
if not spawned:
msg = "; ".join(errors)[:500] or "No scans were launched."
raise _bad(msg)
sched.last_run_at = utc_now()
sched.total_run_count += 1
sched.last_error = "; ".join(errors)[:2000] if errors else None
await self.session.commit()
return spawned
async def delete(self, id: UUID, project_id: UUID) -> None:
sched = await self._get(id, project_id)
await self.session.delete(sched)
await self.session.commit()