diff --git a/alembic/versions/2026_09_27_settings_status.py b/alembic/versions/2026_09_27_settings_status.py new file mode 100644 index 000000000..8d78c23b7 --- /dev/null +++ b/alembic/versions/2026_09_27_settings_status.py @@ -0,0 +1,52 @@ +"""api_keys test status, notification_channels delivery status, proxies without mode + +Revision ID: 5d2e9a8c1b47 +Revises: a1f4c7d20e93 +Create Date: 2026-09-27 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "5d2e9a8c1b47" +down_revision: str | None = "a1f4c7d20e93" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "api_keys", sa.Column("last_test_at", sa.DateTime(timezone=True), nullable=True) + ) + op.add_column("api_keys", sa.Column("last_test_ok", sa.Boolean(), nullable=True)) + op.add_column( + "api_keys", sa.Column("last_test_message", sa.String(length=500), nullable=True) + ) + op.add_column( + "notification_channels", + sa.Column("last_sent_at", sa.DateTime(timezone=True), nullable=True), + ) + op.add_column( + "notification_channels", sa.Column("last_sent_ok", sa.Boolean(), nullable=True) + ) + op.add_column( + "notification_channels", + sa.Column("last_sent_message", sa.String(length=500), nullable=True), + ) + op.drop_column("proxies", "mode") + + +def downgrade() -> None: + op.add_column( + "proxies", + sa.Column("mode", sa.String(), nullable=False, server_default="single"), + ) + op.drop_column("notification_channels", "last_sent_message") + op.drop_column("notification_channels", "last_sent_ok") + op.drop_column("notification_channels", "last_sent_at") + op.drop_column("api_keys", "last_test_message") + op.drop_column("api_keys", "last_test_ok") + op.drop_column("api_keys", "last_test_at") diff --git a/api/app/api/v1/api_keys.py b/api/app/api/v1/api_keys.py index defece03e..89976559d 100644 --- a/api/app/api/v1/api_keys.py +++ b/api/app/api/v1/api_keys.py @@ -89,7 +89,11 @@ async def list_providers( _current_user: CurrentSuperuser, service: Annotated[APIKeyService, Depends(get_api_key_service)], ): - return await service.list_providers() + providers = await service.list_providers() + return [ + p.model_copy(update={"testable": p.provider in API_KEY_TESTERS}) + for p in providers + ] @router.get("", response_model=list[APIKeyRead]) @@ -161,13 +165,15 @@ async def test_api_key( return { "provider": api_key.provider, "success": False, - "message": f"No test available for {api_key.provider.value}", + "message": f"No key test for {api_key.provider.value}.", } key_value = try_decrypt(api_key.key_value) or api_key.key_value try: result = await tester(key_value, api_key.key_meta) - return {"provider": api_key.provider, "success": True, **result} except Exception as e: message = str(e).replace(key_value, MASK) if key_value else str(e) + await service.record_test(api_key, False, message) return {"provider": api_key.provider, "success": False, "message": message} + await service.record_test(api_key, True, result.get("message", "")) + return {"provider": api_key.provider, "success": True, **result} diff --git a/api/app/api/v1/users.py b/api/app/api/v1/users.py index 9b1014ff6..509ca1f7f 100644 --- a/api/app/api/v1/users.py +++ b/api/app/api/v1/users.py @@ -2,12 +2,21 @@ from typing import Annotated from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select from app.api.deps import CurrentSuperuser, CurrentUser from app.core.database import get_session -from shared.models.user import User, UserRead, UserSummary +from app.core.security import hash_password +from app.utils.validation import validate_password_strength, validate_username +from shared.models.user import ( + User, + UserAdminCreate, + UserAdminUpdate, + UserRead, + UserSummary, +) router = APIRouter(prefix="/users", tags=["users"]) @@ -34,11 +43,78 @@ async def list_users( skip: Annotated[int, Query(ge=0, le=1_000_000)] = 0, limit: Annotated[int, Query(ge=1, le=500)] = 100, ): - query = select(User).offset(skip).limit(limit) + query = select(User).order_by(User.created_at).offset(skip).limit(limit) result = await session.execute(query) return result.scalars().all() +@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED) +async def create_user( + data: UserAdminCreate, + session: Annotated[AsyncSession, Depends(get_session)], + current_user: CurrentSuperuser, # noqa: ARG001 +): + username = data.username.strip() + email = data.email.strip().lower() + try: + validate_username(username) + validate_password_strength(data.password, user_inputs=[email, username]) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e) + ) from e + if "@" not in email: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Enter a valid email address.", + ) + taken = await session.scalar( + select(User).where((User.email == email) | (User.username == username)) + ) + if taken is not None: + field = "email" if taken.email == email else "username" + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"A user with this {field} exists.", + ) + user = User( + email=email, + username=username, + hashed_password=hash_password(data.password), + is_superuser=data.is_superuser, + ) + session.add(user) + await session.commit() + await session.refresh(user) + return user + + +@router.patch("/{user_id}", response_model=UserRead) +async def update_user( + user_id: UUID, + data: UserAdminUpdate, + session: Annotated[AsyncSession, Depends(get_session)], + current_user: CurrentSuperuser, +): + if user_id == current_user.id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="The signed-in account cannot change its own role or status.", + ) + user = await session.get(User, user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="User not found" + ) + if data.is_active is not None: + user.is_active = data.is_active + if data.is_superuser is not None: + user.is_superuser = data.is_superuser + await session.commit() + await session.refresh(user) + return user + + @router.get("/{user_id}", response_model=UserRead) async def get_user( user_id: str, @@ -94,7 +170,15 @@ async def delete_user( detail="User not found", ) + username = user.username await session.delete(user) - await session.commit() + try: + await session.commit() + except IntegrityError as e: + await session.rollback() + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=f"{username} created records on this instance. Disable the account instead.", + ) from e - return {"message": f"User {user.username} deleted"} + return {"message": f"User {username} deleted"} diff --git a/api/app/services/instance_settings.py b/api/app/services/instance_settings.py index 62e1a509e..f3cb29fa8 100644 --- a/api/app/services/instance_settings.py +++ b/api/app/services/instance_settings.py @@ -9,6 +9,11 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.crypto import encrypt_secret, try_decrypt from shared.definitions.mode_features import VALID_MODES, capabilities_for +from shared.definitions.retention import ( + KEEP_FOREVER, + SCAN_RETENTION_DAYS, + SCREENSHOT_RETENTION_DAYS, +) from shared.enums.instance import AIProvider from shared.http import get_async_client from shared.logging import get_logger @@ -65,6 +70,15 @@ async def _admit_on_change(changed: bool) -> None: logger.warning("scan admission dispatch failed", exc_info=True) +def _check_window(label: str, days: int, allowed: tuple[int, ...]) -> None: + if days not in allowed: + choices = ", ".join(str(d) for d in allowed if d != KEEP_FOREVER) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{label} retention must be {choices} days, or {KEEP_FOREVER} to keep forever.", + ) + + class InstanceSettingsService: def __init__(self, session: AsyncSession): self.session = session @@ -124,7 +138,13 @@ class InstanceSettingsService: settings = await self.get_or_create() if data.instance_name is not None: - settings.instance_name = data.instance_name + name = data.instance_name.strip() + if not name: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Instance name is required.", + ) + settings.instance_name = name[:120] if data.timezone is not None: try: ZoneInfo(data.timezone) @@ -140,8 +160,16 @@ class InstanceSettingsService: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg) settings.mode = data.mode if data.scan_history_retention_days is not None: + _check_window( + "Scan history", data.scan_history_retention_days, SCAN_RETENTION_DAYS + ) settings.scan_history_retention_days = data.scan_history_retention_days if data.screenshot_retention_days is not None: + _check_window( + "Screenshot", + data.screenshot_retention_days, + SCREENSHOT_RETENTION_DAYS, + ) settings.screenshot_retention_days = data.screenshot_retention_days if data.cert_recheck_enabled is not None: settings.cert_recheck_enabled = data.cert_recheck_enabled diff --git a/api/app/services/notification_channel.py b/api/app/services/notification_channel.py index 5bb7b8970..88e405b93 100644 --- a/api/app/services/notification_channel.py +++ b/api/app/services/notification_channel.py @@ -152,6 +152,9 @@ def _to_read(channel: NotificationChannel) -> NotificationChannelRead: last_test_at=channel.last_test_at, last_test_ok=channel.last_test_ok, last_test_message=channel.last_test_message, + last_sent_at=channel.last_sent_at, + last_sent_ok=channel.last_sent_ok, + last_sent_message=channel.last_sent_message, ) diff --git a/api/app/services/proxy.py b/api/app/services/proxy.py index f2c7e4082..c6a740f28 100644 --- a/api/app/services/proxy.py +++ b/api/app/services/proxy.py @@ -7,13 +7,12 @@ from uuid import UUID import httpx from fastapi import HTTPException, status -from sqlalchemy import select, text, update +from sqlalchemy import func, select, text, update from sqlalchemy.ext.asyncio import AsyncSession from app.core.crypto import encrypt_secret from shared.http import get_async_client from shared.models.proxy import ( - PROXY_MODES, PROXY_SCHEMES, Proxy, ProxyCreate, @@ -23,6 +22,7 @@ from shared.models.proxy import ( ProxyTestResult, ProxyUpdate, ) +from shared.models.scan_context import ScanContext from shared.services.proxy_resolve import ( build_proxy_url as _build_url, ) @@ -42,18 +42,13 @@ _TCP_TIMEOUT = 6.0 _MIN_PORT = 1 _MAX_PORT = 65535 _DEFAULT_LOCK_KEY = 0x70726F78 +_MAX_TESTED = 10 def _bad(detail: str) -> HTTPException: return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail) -def _validate_mode(mode: str) -> None: - if mode not in PROXY_MODES: - msg = f"Invalid mode. Must be one of {', '.join(PROXY_MODES)}." - raise _bad(msg) - - def _validate_endpoints(endpoints: list[ProxyEndpoint]) -> None: if not endpoints: msg = "At least one endpoint is required." @@ -77,10 +72,11 @@ def _endpoint_key(ep: ProxyEndpoint) -> tuple: def _mask_url(ep: ProxyEndpoint) -> str: + authority = host_port(ep.host, ep.port) if ep.username: cred = f"{ep.username}:{MASK}@" if ep.password else f"{ep.username}@" - return f"{ep.scheme}://{cred}{ep.host}:{ep.port}" - return f"{ep.scheme}://{ep.host}:{ep.port}" + return f"{ep.scheme}://{cred}{authority}" + return f"{ep.scheme}://{authority}" def _endpoint_to_read(ep: ProxyEndpoint) -> ProxyEndpointRead: @@ -99,21 +95,46 @@ def _encrypt_endpoints(endpoints: list[ProxyEndpoint]) -> str: return encrypt_secret(json.dumps(payload)) +def _summarize( + endpoints: list[ProxyEndpoint], results: list[ProxyTestResult] +) -> ProxyTestResult: + if len(results) == 1: + return results[0] + passed = [r for r in results if r.success] + if len(passed) == len(results): + return ProxyTestResult( + success=True, + message=f"All {len(results)} endpoints carried the request.", + latency_ms=max(r.latency_ms or 0 for r in passed), + reachable=True, + ) + failed = next(i for i, r in enumerate(results) if not r.success) + ep = endpoints[failed] + return ProxyTestResult( + success=False, + message=( + f"{len(passed)} of {len(results)} endpoints carried the request. " + f"{host_port(ep.host, ep.port)}: {results[failed].message}" + ), + reachable=all(r.reachable for r in results), + ) + + class ProxyService: def __init__(self, session: AsyncSession): self.session = session - def to_read(self, proxy: Proxy) -> ProxyRead: + def to_read(self, proxy: Proxy, contexts: int = 0) -> ProxyRead: endpoints = _load_endpoints(proxy) return ProxyRead( id=proxy.id, name=proxy.name, description=proxy.description, - mode=proxy.mode, is_active=proxy.is_active, is_default=proxy.is_default, endpoints=[_endpoint_to_read(e) for e in endpoints], endpoint_count=proxy.endpoint_count, + contexts=contexts, created_at=proxy.created_at, updated_at=proxy.updated_at, last_test_at=proxy.last_test_at, @@ -121,24 +142,37 @@ class ProxyService: last_test_message=proxy.last_test_message, ) + async def _read(self, proxy: Proxy) -> ProxyRead: + used = await self.session.scalar( + select(func.count()).where(ScanContext.proxy_id == proxy.id) + ) + return self.to_read(proxy, int(used or 0)) + async def list(self) -> list[ProxyRead]: result = await self.session.execute( select(Proxy).order_by(Proxy.is_default.desc(), Proxy.updated_at.desc()) ) - return [self.to_read(p) for p in result.scalars().all()] + used = dict( + ( + await self.session.execute( + select(ScanContext.proxy_id, func.count()) + .where(ScanContext.proxy_id.is_not(None)) + .group_by(ScanContext.proxy_id) + ) + ).all() + ) + return [self.to_read(p, used.get(p.id, 0)) for p in result.scalars().all()] async def get(self, id: UUID) -> ProxyRead: proxy = await self._get_or_404(id) - return self.to_read(proxy) + return await self._read(proxy) async def create(self, data: ProxyCreate, created_by: UUID) -> ProxyRead: - _validate_mode(data.mode) _validate_endpoints(data.endpoints) proxy = Proxy( name=data.name, description=data.description, - mode=data.mode, is_active=data.is_active, is_default=data.is_default, endpoints_encrypted=_encrypt_endpoints(data.endpoints), @@ -151,7 +185,7 @@ class ProxyService: await self._unset_other_defaults(proxy.id) await self.session.commit() await self.session.refresh(proxy) - return self.to_read(proxy) + return await self._read(proxy) async def update(self, id: UUID, data: ProxyUpdate) -> ProxyRead: proxy = await self._get_or_404(id) @@ -160,9 +194,6 @@ class ProxyService: proxy.name = data.name if data.description is not None: proxy.description = data.description - if data.mode is not None: - _validate_mode(data.mode) - proxy.mode = data.mode if data.is_active is not None: proxy.is_active = data.is_active @@ -181,10 +212,15 @@ class ProxyService: await self._unset_other_defaults(proxy.id) await self.session.commit() await self.session.refresh(proxy) - return self.to_read(proxy) + return await self._read(proxy) async def delete(self, id: UUID) -> bool: proxy = await self._get_or_404(id) + await self.session.execute( + update(ScanContext) + .where(ScanContext.proxy_id == proxy.id) + .values(proxy_id=None) + ) await self.session.delete(proxy) await self.session.commit() return True @@ -197,7 +233,7 @@ class ProxyService: proxy.updated_at = utc_now() await self.session.commit() await self.session.refresh(proxy) - return self.to_read(proxy) + return await self._read(proxy) async def test(self, id: UUID) -> ProxyTestResult: proxy = await self._get_or_404(id) @@ -208,14 +244,27 @@ class ProxyService: ProxyTestResult(success=False, message="No endpoints configured."), ) - ep = endpoints[0] - url = _build_url(ep) - result = await self._probe(ep, url) - return await self._persist_test(proxy, result) + tested = endpoints[:_MAX_TESTED] + results = await asyncio.gather( + *(self._probe(ep, _build_url(ep)) for ep in tested) + ) + return await self._persist_test(proxy, _summarize(tested, results)) - async def resolve_proxy_url(self, proxy_id: UUID) -> str | None: - proxy = await self.session.get(Proxy, proxy_id) - return _resolve_proxy_url(proxy) + async def scan_proxy(self, context: ScanContext | None) -> Proxy | None: + """The context's proxy, or the default proxy for a scan without a context.""" + if context is None: + result = await self.session.execute( + select(Proxy).where( + Proxy.is_default.is_(True), Proxy.is_active.is_(True) + ) + ) + return result.scalars().first() + if context.proxy_id is None: + return None + return await self.session.get(Proxy, context.proxy_id) + + async def scan_proxy_url(self, context: ScanContext | None) -> str | None: + return _resolve_proxy_url(await self.scan_proxy(context)) def _merge_endpoints( self, proxy: Proxy, incoming: list[ProxyEndpoint] diff --git a/api/app/services/scan.py b/api/app/services/scan.py index 6e1d14940..bab406417 100644 --- a/api/app/services/scan.py +++ b/api/app/services/scan.py @@ -321,11 +321,7 @@ class ScanService: if context_id is not None: context = await self._get_context(context_id, project_id) - 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 - ) + proxy_url = await ProxyService(self.session).scan_proxy_url(context) return engine, context, proxy_url @staticmethod @@ -477,14 +473,8 @@ class ScanService: 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 + 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), diff --git a/api/app/services/vuln_template.py b/api/app/services/vuln_template.py index 32b4869e5..ea9bf02b5 100644 --- a/api/app/services/vuln_template.py +++ b/api/app/services/vuln_template.py @@ -215,9 +215,13 @@ class VulnTemplateService: fired = await self.session.scalar( select(func.count(func.distinct(Vulnerability.template_id))) ) + callback = await self.session.scalar( + select(func.count(VulnTemplate.id)).where(VulnTemplate.needs_oast.is_(True)) + ) return TemplateLibraryStats( ready=total > 0, total=total, + callback=int(callback or 0), official=by_origin.get(TemplateOrigin.OFFICIAL.value, 0), custom=by_origin.get(TemplateOrigin.CUSTOM.value, 0), by_severity=[ @@ -274,6 +278,8 @@ class VulnTemplateService: ) if f.fired: query = query.where(hits.c.findings > 0) + if f.callback: + query = query.where(VulnTemplate.needs_oast.is_(True)) if f.new_since is not None: query = query.where( VulnTemplate.origin == TemplateOrigin.OFFICIAL.value, diff --git a/frontend/src/lib/api/users.ts b/frontend/src/lib/api/users.ts index 5c0df0887..f1e7d8117 100644 --- a/frontend/src/lib/api/users.ts +++ b/frontend/src/lib/api/users.ts @@ -12,6 +12,19 @@ export interface UserAccount { is_active: boolean; is_superuser: boolean; totp_enabled: boolean; + created_at: string; +} + +export interface UserAccountCreate { + username: string; + email: string; + password: string; + is_superuser: boolean; +} + +export interface UserAccountUpdate { + is_active?: boolean; + is_superuser?: boolean; } const cache = new Map>(); @@ -21,6 +34,18 @@ export const usersApi = { return api.get('/users?limit=500'); }, + create(data: UserAccountCreate): Promise { + return api.post('/users', data); + }, + + update(userId: string, data: UserAccountUpdate): Promise { + return api.patch(`/users/${userId}`, data); + }, + + remove(userId: string): Promise { + return api.delete(`/users/${userId}`); + }, + getSummary(userId: string): Promise { const cached = cache.get(userId); if (cached) return cached; diff --git a/frontend/src/lib/components/arsenal/callback-server-sheet.svelte b/frontend/src/lib/components/arsenal/callback-server-sheet.svelte new file mode 100644 index 000000000..3c8b07476 --- /dev/null +++ b/frontend/src/lib/components/arsenal/callback-server-sheet.svelte @@ -0,0 +1,334 @@ + + + + + + {CALLBACK_SERVER} + {#if settings} + + {settings.checks.toLocaleString()} checks need a callback + + {/if} + + + {#if settings} + +
+
+

Mode

+ v && (mode = v as OastMode)} + disabled={!isAdmin || saving} + class="w-fit" + aria-label="Mode" + > + {#each OAST_MODES as option (option)} + + {OAST_MODE_LABELS[option]} + + {/each} + + {#if mode !== OastMode.PUBLIC} +

{OAST_MODE_HELP[mode]}

+ {/if} + {#if settings.reason && settings.mode !== OastMode.OFF && !dirty} +

{settings.reason}

+ {/if} +
+ + {#if mode === OastMode.SELF_HOSTED} +
+

Server

+ (serverError = null)} + autocomplete="off" + spellcheck={false} + disabled={!isAdmin || saving} + aria-label="Server domain" + aria-invalid={serverError ? true : undefined} + class="font-mono text-xs" + /> + {#if serverError} +

{serverError}

+ {/if} +
+ Token + {settings.token_set ? 'Set' : 'Not set'} + + API keys + +
+
+ {/if} + + {#if mode === OastMode.PUBLIC} +
+

Public server

+
+ + {PUBLIC_ACK} +
+ +

+ {settings.public_servers.join(' · ')} +

+
+ {/if} + + {#if mode !== OastMode.OFF} +
+

Wait after each out-of-band batch

+ v !== undefined && (wait = Number(v))} + disabled={!isAdmin || saving} + > + + {waitLabel(wait)} + + + {#each WAIT_STEPS as step (step)} + + {waitLabel(step)} + + {/each} + + +
+ {/if} + + {#if result && !dirty} +
+ {#if result.ok} + + {:else} + + {/if} + {result.detail} +
+ {/if} + + {#if settings.interactions > 0} +
+ {settings.interactions.toLocaleString()} + {settings.interactions === 1 ? 'finding' : 'findings'} with a callback + {#if settings.last_interaction_at} + · most recent {relativeTime(settings.last_interaction_at)} + {/if} +
+ {/if} +
+
+ +
+ + Save + + + Test server + + + +
+ {#if blocked && dirty} +

{blocked}

+ {:else if !isAdmin} +

+ Editable by administrators. +

+ {/if} + {/if} +
+
+ + (resetOpen = v)} + onConfirm={reset} +/> diff --git a/frontend/src/lib/components/arsenal/nuclei-library.svelte b/frontend/src/lib/components/arsenal/nuclei-library.svelte index 06035ceb1..d493fbffc 100644 --- a/frontend/src/lib/components/arsenal/nuclei-library.svelte +++ b/frontend/src/lib/components/arsenal/nuclei-library.svelte @@ -1,6 +1,8 @@ + + + + + {routeLabels['bounty-hub']} settings + + + {#if loadError && !settings} +
+

{loadError}

+ +
+ {:else if !settings} +
+ + + +
+ {:else} + +
+
+

Platforms

+ {#each connectable as platform (platform.platform)} +
+ {platform.label} + {#if platform.configured} + + {platform.programs.toLocaleString()} programs + {#if platform.private_programs > 0} + · {platform.private_programs.toLocaleString()} private + {/if} + + {:else} + + Add key + + {/if} +
+ {/each} +
+ +
+

Sync

+
+
+ Platform APIs + + {schedule(settings.sync_interval, settings.last_synced_at, settings.next_sync_at)} + +
+ v && save({ sync_interval: v }, 'Platform sync')} + disabled={!isAdmin || saving} + > + + {SYNC_INTERVAL_LABELS[settings.sync_interval] ?? settings.sync_interval} + + + {#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)} + {label} + {/each} + + +
+
+
+ Public feed + + {schedule( + settings.feed_interval, + settings.feed_synced_at, + settings.feed_next_sync_at + )} + + + {settings.feed_source} · {settings.feed_license} + +
+ v && save({ feed_interval: v }, 'Feed sync')} + disabled={!isAdmin || saving} + > + + {SYNC_INTERVAL_LABELS[settings.feed_interval] ?? settings.feed_interval} + + + {#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)} + {label} + {/each} + + +
+
+ +
+

Alerts

+ + {#if settings.notify} +
+ {#each specs as spec (spec.kind)} + + {/each} +
+ {/if} +

+ {settings.events_recorded.toLocaleString()} changes recorded · + + Notification channels + +

+
+
+
+ {#if !isAdmin} +

+ Editable by administrators. +

+ {/if} + {/if} +
+
diff --git a/frontend/src/lib/components/bounty-hub/watching-tab.svelte b/frontend/src/lib/components/bounty-hub/watching-tab.svelte index 1d8fc62bd..8c0c48706 100644 --- a/frontend/src/lib/components/bounty-hub/watching-tab.svelte +++ b/frontend/src/lib/components/bounty-hub/watching-tab.svelte @@ -9,7 +9,6 @@ import { SUBMISSION_STATE_LABELS } from '$lib/config/bounty-programs'; import { bountyVocabulary } from '$lib/stores/bounty-vocabulary.svelte'; import { SubmissionState } from '$lib/types/bounty-program'; - import { ROUTES } from '$lib/config/routes'; import { relativeTime } from '$lib/utilities/dates'; import { WatchStatus, @@ -91,7 +90,14 @@ {:else} Certificate stream not running. Check that the ct-stream service is up. {/if} - Details + {#if stream.last_error} + + {stream.last_error} + {#if stream.last_error_at} + · {relativeTime(stream.last_error_at)} + {/if} + + {/if} {/if} diff --git a/frontend/src/lib/components/contexts/proxy-section.svelte b/frontend/src/lib/components/contexts/proxy-section.svelte index b79b62206..001aa5bcb 100644 --- a/frontend/src/lib/components/contexts/proxy-section.svelte +++ b/frontend/src/lib/components/contexts/proxy-section.svelte @@ -88,7 +88,7 @@ {/if} Manage proxies in Settings diff --git a/frontend/src/lib/components/onboarding/step-notifications.svelte b/frontend/src/lib/components/onboarding/step-notifications.svelte index 4dcc9334f..2f0510dd8 100644 --- a/frontend/src/lib/components/onboarding/step-notifications.svelte +++ b/frontend/src/lib/components/onboarding/step-notifications.svelte @@ -22,14 +22,11 @@ import { ONBOARDING_NOTIFICATION_PROVIDERS as PROVIDERS } from '$lib/config/notification-providers'; import type { StepProps } from '$lib/types/onboarding'; import { capabilitiesStore } from '$lib/stores/capabilities.svelte'; - import { Capability } from '$lib/config/capabilities'; import { SvelteSet } from 'svelte/reactivity'; let { next, setFooter }: StepProps = $props(); const categories = $derived( - NOTIF_CATEGORIES.filter( - (c) => c.value !== 'watch' || capabilitiesStore.has(Capability.PROGRAM_WATCHES) - ) + NOTIF_CATEGORIES.filter((c) => !c.capability || capabilitiesStore.has(c.capability)) ); interface ChannelDraft { diff --git a/frontend/src/lib/components/onboarding/step-proxy.svelte b/frontend/src/lib/components/onboarding/step-proxy.svelte index 9dd932ffd..2ce12472d 100644 --- a/frontend/src/lib/components/onboarding/step-proxy.svelte +++ b/frontend/src/lib/components/onboarding/step-proxy.svelte @@ -153,7 +153,6 @@ if (!endpoints) return null; const created = await proxiesApi.create({ name: `${data.instanceName || 'reNgine'} Proxy`, - mode: choice === 'single' ? 'rotating' : 'single', is_active: true, is_default: asDefault, endpoints diff --git a/frontend/src/lib/components/scans/launch/launch-context-form.svelte b/frontend/src/lib/components/scans/launch/launch-context-form.svelte index 974e7527e..e9295ad7a 100644 --- a/frontend/src/lib/components/scans/launch/launch-context-form.svelte +++ b/frontend/src/lib/components/scans/launch/launch-context-form.svelte @@ -1,5 +1,5 @@ - - - -
-
-
- -
-
- -

- {provider.description} -

-
-
- - {#if apiKey} - onToggle(provider.provider, checked)} - /> - {/if} -
- - {#if apiKey} - - -
- {#if provider.requires_username && apiKey.key_meta?.username} -
- - - {apiKey.key_meta.username} - -
- {/if} -
- - - {apiKey.key_value_masked} - - - { - if (open) reveal(apiKey.id); - else resetReveal(); - }} - > - - {#snippet child({ props })} - - {/snippet} - - -
-
- - {#if revealedKeyValue} - - {/if} -
- {#if revealLoading} -
- -
- {:else if revealedKeyValue} - - {revealedKeyValue} - - {:else if revealError} -
-

Key not loaded

- -
- {/if} -
-
-
- - {#if apiKey.is_enabled} - Active - {:else} - - Disabled - - {/if} -
- -
- - - {#snippet child({ props })} - - - {apiKey.usage_counter} calls - - {/snippet} - - API calls - - - {#if apiKey.last_used_at} - Last used {formatDate(apiKey.last_used_at)} - {:else} - Unused - {/if} -
- -
- { - if (open) { - editOpen = true; - editKeyValue = ''; - editUsername = (apiKey.key_meta?.username as string) ?? ''; - } else { - editOpen = false; - resetEdit(); - } - }} - > - - {#snippet child({ props })} - - {/snippet} - - -
-
-

Update API key

-
- {#if provider.requires_username} - - {#snippet children({ id })} - - {/snippet} - - {/if} - - {#snippet children({ id })} -
- - -
- {/snippet} -
- - Update key - -
-
-
- - - - {#snippet child({ props })} - - {/snippet} - - Test key - - -
- - - - {#snippet child({ props })} - - {/snippet} - - Remove API key - -
-
- {:else} -
- -
- {/if} - - diff --git a/frontend/src/lib/components/settings/api-keys-panel.svelte b/frontend/src/lib/components/settings/api-keys-panel.svelte index 16079fff2..9a16a6c4a 100644 --- a/frontend/src/lib/components/settings/api-keys-panel.svelte +++ b/frontend/src/lib/components/settings/api-keys-panel.svelte @@ -1,71 +1,96 @@ -
-
-
-

API keys

-

- Keys for external intelligence providers. Shared by every project. -

-
- - {configuredKeys.size} / {providers.length} configured - -
- - - - {#if isLoading} -
- {#each Array(6) as _, i (i)} - - -
- -
- - - -
+{#if !isAdmin} + +{:else if loading} + + + + + + +{:else if loadError} + + + +{:else} + +
+
+
Provider
+
Key
+
Status
+
Last tested
+
+ {setCount} of {providers.length} set +
+
+ {#each groups as group (group.group)} +
{group.label}
+ {#each group.items as provider (provider.provider)} + {@const key = keys.get(provider.provider)} + {@const check = stateOf(provider)} +
+
+ + {provider.name} + + + + + {provider.description}
- - +
+ {#if key} +
+ + + {key.key_value_masked} + + {#if key.key_meta?.username} + + {key.key_meta.username} + + {/if} + + { + if (open) void reveal(key); + else revealed = null; + }} + > + + {#snippet child({ props })} + + {/snippet} + + + {#if revealed} +
+ + {revealed} + + +
+ {:else if revealFailed} +

Key not loaded.

+ {:else} +
+ {/if} +
+
+
+ {:else} + Not set + {/if} +
+
+ {#if key} + + {#snippet child(props)} + + {#if testing === provider.provider} + + Testing + {:else} + + + {STATUS_LABEL[check]} + + {/if} + + {/snippet} + + {:else} + + + Not set + + {/if} +
+
+ {key?.last_test_at ? relativeTime(key.last_test_at) : 'Never'} +
+
+ {#if key} + + + {#snippet child({ props })} + + {/snippet} + + + {#if provider.testable} + test(provider)} + > + Test + + {/if} + openDialog(provider)}> + Replace key + + setEnabled(provider, !key.is_enabled)}> + {key.is_enabled ? 'Disable' : 'Enable'} + + + (removing = provider)}> + Remove + + + + {:else} + + {/if} +
+
+ {/each} {/each}
- {:else if loadError} - - - - {:else} -
- {#each providers as provider (provider.provider)} - - {/each} -
- {/if} -
+
+{/if} - + - Add API key - - {#if addDialogProvider} - {@const meta = providers.find((p) => p.provider === addDialogProvider)} - {#if meta} - - Get a {meta.name} key - - {/if} - {/if} - + + {editing ? `${replacing ? 'Replace' : 'Add'} ${editing.name} key` : 'API key'} + + {#if editing} + + + {editing.name} documentation + + + {/if} -
{ e.preventDefault(); - if (addCanSave) handleAdd(); + void saveKey(); }} > -
- {#if addRequiresUsername} - - {#snippet children({ id })} - - {/snippet} - - {/if} - + {#if editing?.requires_username} + {#snippet children({ id })} -
- - -
+ {/snippet}
-
- + {/if} + + {#snippet children({ id })} +
+ + +
+ {/snippet} +
- - Add key + + {replacing ? 'Replace key' : 'Add key'}
- p.provider === deletingProvider)?.name ?? 'provider'} key is removed.` - : ''} + description={removing ? `The ${removing.name} key is removed.` : ''} confirmLabel="Remove" - {isDeleting} - onOpenChange={(open) => (deleteDialogOpen = open)} - onConfirm={handleDelete} + destructive + loading={deleting} + onOpenChange={(open) => { + if (!open) removing = null; + }} + onConfirm={remove} /> - - - - - Test API key - - One call is made against the - {providers.find((p) => p.provider === testDialogProvider)?.name ?? 'provider'} quota. - - - - - { - if (testDialogKeyId) { - testDialogOpen = false; - await handleTest(testDialogKeyId); - } - }} - > - Test - - - - diff --git a/frontend/src/lib/components/settings/bounty-hub-panel.svelte b/frontend/src/lib/components/settings/bounty-hub-panel.svelte deleted file mode 100644 index cfdccdd61..000000000 --- a/frontend/src/lib/components/settings/bounty-hub-panel.svelte +++ /dev/null @@ -1,276 +0,0 @@ - - -{#if loading} -
- {#each Array(3) as _, i (i)} -
- - -
- {#each Array(3) as _, j (j)} -
- - -
- {/each} -
-
- {/each} -
-{:else if settings} -
- - - -
- - {#if settings.last_synced_at} - Last synced {relativeTime(settings.last_synced_at)} - {/if} - {#if settings.next_sync_at} - Next {relativeTime(settings.next_sync_at)} - {/if} - -
- - {#each connected as platform (platform.platform)} -
- {platform.label} - - {platform.programs.toLocaleString()} programs - {#if platform.private_programs > 0} - · {platform.private_programs.toLocaleString()} private - {/if} - -
- {/each} - -
-
- Sync interval -
- v && save({ sync_interval: v })} - > - - {SYNC_INTERVAL_LABELS[settings.sync_interval] ?? settings.sync_interval} - - - {#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)} - {label} - {/each} - - -
- - {#if settings.sync_interval === 'off'} -
- Programs update on manual refresh only. -
- {/if} -
- - -
- - {#if settings.feed_synced_at} - Last synced {relativeTime(settings.feed_synced_at)} - {/if} - {#if settings.feed_next_sync_at} - Next {relativeTime(settings.feed_next_sync_at)} - {/if} - -
- - {#each fedPlatforms as platform (platform.platform)} -
- {platform.label} - - {platform.feed_programs.toLocaleString()} programs - -
- {/each} - -
-
- Sync interval - - From - - {settings.feed_source} - - under {settings.feed_license}. Public programs only. - -
- v && save({ feed_interval: v })} - > - - {SYNC_INTERVAL_LABELS[settings.feed_interval] ?? settings.feed_interval} - - - {#each Object.entries(SYNC_INTERVAL_LABELS) as [value, label] (value)} - {label} - {/each} - - -
-
- - - - -
-
- Notify on program changes - - Sent to the inbox and every notification channel. - -
- save({ notify: v })} - /> -
- - {#if settings.notify} -
- {#each specs as spec (spec.kind)} - {@const Icon = assetIcon(spec.icon)} - {@const on = settings.notify_events.includes(spec.kind)} -
-
- - - -
- {spec.label} - {spec.description} -
-
- toggleEvent(spec.kind, v)} - /> -
- {/each} -
- {:else} -
- Changes are recorded in the Updates tab. Nothing is sent. -
- {/if} -
-
-{:else} - - - -{/if} diff --git a/frontend/src/lib/components/settings/certificate-stream-card.svelte b/frontend/src/lib/components/settings/certificate-stream-card.svelte deleted file mode 100644 index 94a8379ad..000000000 --- a/frontend/src/lib/components/settings/certificate-stream-card.svelte +++ /dev/null @@ -1,99 +0,0 @@ - - - -
- - Certificate transparency for the watched apexes - -
-
-
- Stream - {streamState.label} -
-
- Watched apexes - {stream ? stream.items : 'Not read'} -
-
- Last certificate - - {#if !stream} - Not read - {:else} - {stream.last_certificate_at ? relativeTime(stream.last_certificate_at) : 'None yet'} - {/if} - -
-
- Since start - - {#if !stream} - Not read - {:else} - {stream.certificates_seen} - {stream.certificates_seen === 1 ? 'certificate' : 'certificates'} - {#if stream.started_at} - - · {relativeTime(stream.started_at)} - - {/if} - {/if} - -
-
- {#if stream?.last_error} -
- {stream.last_error} - {#if stream.last_error_at} - · {relativeTime(stream.last_error_at)} - {/if} -
- {:else if failed} -
- The API did not respond. Check that the api service is running. -
- {:else if stream && !stream.reachable} -
- The ct-stream service has not reported. Check that it is running. -
- {/if} -
diff --git a/frontend/src/lib/components/settings/columns.ts b/frontend/src/lib/components/settings/columns.ts new file mode 100644 index 000000000..17c2930ad --- /dev/null +++ b/frontend/src/lib/components/settings/columns.ts @@ -0,0 +1,40 @@ +export const KEY_COL = { + provider: 'min-w-0 flex-1', + key: 'hidden w-[190px] shrink-0 @2xl/keys:block', + status: 'w-[112px] shrink-0', + tested: 'hidden w-[104px] shrink-0 @xl/keys:block', + actions: 'flex w-[84px] shrink-0 justify-end' +} as const; + +export const PROXY_COL = { + proxy: 'min-w-0 flex-1', + endpoint: 'hidden w-[230px] shrink-0 @2xl/proxies:block', + contexts: 'hidden w-[96px] shrink-0 @xl/proxies:block', + status: 'w-[128px] shrink-0', + actions: 'flex w-8 shrink-0 justify-end' +} as const; + +export const CHANNEL_COL = { + channel: 'min-w-0 flex-1', + events: 'hidden w-[190px] shrink-0 @2xl/channels:block', + level: 'hidden w-[150px] shrink-0 @xl/channels:block', + delivery: 'w-[140px] shrink-0', + actions: 'flex w-8 shrink-0 justify-end' +} as const; + +export const USER_COL = { + user: 'min-w-0 flex-1', + role: 'w-[104px] shrink-0', + twoFactor: 'hidden w-[104px] shrink-0 @xl/users:block', + created: 'hidden w-[120px] shrink-0 @2xl/users:block', + actions: 'flex w-8 shrink-0 justify-end' +} as const; + +export const HEAD_ROW = + 'flex items-center gap-4 border-b bg-muted/20 px-4 py-2 text-2xs font-medium tracking-wide text-muted-foreground uppercase'; + +export const BODY_ROW = + 'flex items-center gap-4 border-b border-border/60 px-4 py-2.5 last:border-b-0'; + +export const GROUP_ROW = + 'border-b border-border/60 bg-muted/10 px-4 pt-3 pb-1.5 text-2xs font-semibold tracking-[0.08em] text-muted-foreground uppercase'; diff --git a/frontend/src/lib/components/settings/instance-settings-panel.svelte b/frontend/src/lib/components/settings/instance-settings-panel.svelte index c5abdabe4..467714cbd 100644 --- a/frontend/src/lib/components/settings/instance-settings-panel.svelte +++ b/frontend/src/lib/components/settings/instance-settings-panel.svelte @@ -1,385 +1,239 @@ -
-
-

General

-

- Instance identity, scan concurrency, retention windows and AI analysis. -

-
+{#if loading && !settings} + + + + + + +{:else if !settings} + + + +{:else} + +
+ + e.key === 'Enter' && e.currentTarget.blur()} + /> + + + save({ timezone: zone }, 'Time zone')} + /> + + + v && v !== settings.mode && save({ mode: v }, 'Mode')} + disabled={locked} + > + + {MODE_LABELS[coerceInstanceMode(settings.mode)]} + + + {#each INSTANCE_MODES as mode (mode.value)} + {mode.label} + {/each} + + + - +
+ + + v !== undefined && + Number(v) !== settings.concurrent_scans && + save({ concurrent_scans: Number(v) }, 'Concurrent scans')} + disabled={locked} + > + + {concurrency.find((o) => o.value === String(settings.concurrent_scans))?.label ?? + settings.concurrent_scans} + + + {#each concurrency as option (option.value)} + {option.label} + {/each} + + + + + save({ cert_recheck_enabled: v }, 'Certificate re-check')} + /> + - {#if isLoading} -
- - -
- {:else if loadFailed} - - - - {:else} - - - Instance - Name, time zone and mode. - - -
-
- - -
-
- - - {timezone} - - {#each zoneOptions as tz (tz)} - {tz} - {/each} - - -
-
+
+ + + v !== undefined && + Number(v) !== settings.scan_history_retention_days && + save({ scan_history_retention_days: Number(v) }, 'Scan history retention')} + disabled={locked} + > + + {retentionLabel(SCAN_RETENTION, String(settings.scan_history_retention_days))} + + + {#each SCAN_RETENTION as option (option.value)} + {option.label} + {/each} + + + + + + v !== undefined && + Number(v) !== settings.screenshot_retention_days && + save({ screenshot_retention_days: Number(v) }, 'Evidence retention')} + disabled={locked} + > + + {retentionLabel(SCREENSHOT_RETENTION, String(settings.screenshot_retention_days))} + + + {#each SCREENSHOT_RETENTION as option (option.value)} + {option.label} + {/each} + + + -
- - - {modePlaceholder} - - {#each INSTANCE_MODES as m (m.value)} - {m.label} - {/each} - - -

- Sets which integrations and scoping options appear. -

-
-
-
- - - - Data retention - Runs and their evidence are pruned daily. - - -
-
- - - - {SCAN_RETENTION.find((o) => o.value === scanRetention)?.label ?? '90 days'} - - - {#each SCAN_RETENTION as o (o.value)} - {o.label} - {/each} - - -
-
- - - - {SCREENSHOT_RETENTION.find((o) => o.value === screenshotRetention)?.label ?? - '30 days'} - - - {#each SCREENSHOT_RETENTION as o (o.value)} - {o.label} - {/each} - - -
-
-
-
- - - - Scans - Scans past the limit wait in launch order. - - -
- - - - {concurrencyOptions.find((o) => o.value === concurrentScans)?.label ?? - concurrentScans} - - - {#each concurrencyOptions as o (o.value)} - {o.label} - {/each} - - -

- Automatic follows the stage slots of the running scan workers. -

-
-
-
- - - - Between scans - Opens one TLS connection to each web asset every few hours to refresh certificate expiry. - - -
- - -
-
-
- - - -
- - AI -
- Model connection, feature opt-ins and cost. -
- - - -
- -
- {#if isDirty} - Unsaved changes - {/if} - -
- {/if} -
- - { - showLeaveDialog = o; - if (!o) pendingNav = null; - }} - onConfirm={() => { - showLeaveDialog = false; - const resume = pendingNav; - pendingNav = null; - resume?.(); - }} -/> + {#if !isAdmin} +
+ Editable by administrators. +
+ {/if} + +{/if} diff --git a/frontend/src/lib/components/settings/notification-channels-panel.svelte b/frontend/src/lib/components/settings/notification-channels-panel.svelte index e5786a57f..0addbe327 100644 --- a/frontend/src/lib/components/settings/notification-channels-panel.svelte +++ b/frontend/src/lib/components/settings/notification-channels-panel.svelte @@ -1,58 +1,64 @@ -
-
-
-

Notifications

-

Scan events sent to external channels.

-
+{#snippet addAction()} + +{/snippet} + +{#if !isAdmin} + +{:else if loading} + + + + + +{:else if loadFailed} + + + +{:else if channels.length === 0} + p.name).join(', ')} + > -
- - - - {#if isLoading} -
- {#each Array(3) as _, i (i)} - - -
- -
- - -
-
-
-
- {/each} -
- {:else if channels.length === 0} - - - -
-

No notification channels

-

- {PROVIDERS.map((p) => p.name).join(', ')} -

-
- -
-
- {:else} -
+ +{:else} + +
+
+
Channel
+
Events
+
Minimum level
+
Last delivery
+
+
{#each channels as channel (channel.id)} {@const meta = metaFor(channel.provider)} - {@const Icon = meta.icon} - - -
-
-
- toggleCheck(channel.id)} - aria-label="Select {channel.name}" - /> -
-
- -
-
-

{channel.name}

-

{meta.name}

-
-
- - notificationChannelsStore.update(channel.id, { is_active: checked }).then((u) => { - if (u) toast.success(checked ? 'Channel enabled' : 'Channel disabled'); - })} - /> -
- - - -
- {#each meta.fields.filter((f) => f.kind !== 'bool') as f (f.key)} -
- {f.label} - {String(channel.config_masked[f.key] ?? '—')} -
- {/each} -
- - {summaryLine(channel)} - - {#if channel.last_test_at} - - {#if channel.last_test_ok} - - {:else} - - {/if} - {formatDate(channel.last_test_at)} - - {/if} -
-
- -
- - -
- - - {#snippet child({ props })} - - {/snippet} - - Remove channel - -
-
-
+ {@const sent = delivery(channel)} +
+
+ {channel.name} + {meta.name} +
+
{eventSummary(channel)}
+
{levelOf(channel)}
+
+ + {#snippet child(props)} + + {#if testingId === channel.id} + + Sending + {:else} + + + {sent.label} + + {/if} + + {/snippet} + +
+
+ + + {#snippet child({ props })} + + {/snippet} + + + test(channel)} + > + Send test + + openEdit(channel)}>Edit + setActive(channel, !channel.is_active)}> + {channel.is_active ? 'Disable' : 'Enable'} + + + (removing = channel)}> + Remove + + + +
+
{/each}
- {/if} -
+ +{/if} {editingId ? 'Edit channel' : 'Add channel'} - Connection and events. @@ -507,7 +465,7 @@ {#each PROVIDERS as p (p.provider)} {@const PIcon = p.icon}