Files
rengine/shared/services/api_key/async_api_key.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

209 lines
7.3 KiB
Python

from __future__ import annotations
import uuid
from http import HTTPStatus
from typing import TYPE_CHECKING
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from shared.definitions.mode_features import provider_allowed
from shared.enums.api_key import APIProvider
from shared.enums.instance import InstanceMode
from shared.models.api_key import (
API_PROVIDER_META,
APIKey,
APIKeyCreate,
APIKeyRead,
APIKeyUpdate,
ProviderInfo,
)
from shared.models.instance_settings import SINGLETON_KEY, InstanceSettings
from shared.utils.crypto import encrypt_secret, try_decrypt
from shared.utils.datetime import utc_now
if TYPE_CHECKING:
from fastapi import HTTPException
def _http_error(status: HTTPStatus, detail: str) -> HTTPException:
from fastapi import HTTPException # noqa: PLC0415
return HTTPException(status_code=status, detail=detail)
class APIKeyService:
def __init__(self, session: AsyncSession):
self.session = session
async def _instance_mode(self) -> str:
result = await self.session.execute(
select(InstanceSettings.mode).where(
InstanceSettings.singleton_key == SINGLETON_KEY
)
)
return result.scalar_one_or_none() or InstanceMode.BUG_BOUNTY.value
def _mask_key(self, key_value: str) -> str:
if len(key_value) <= 4: # noqa: PLR2004
return "****"
return f"****{key_value[-4:]}"
def _to_read(self, api_key: APIKey) -> APIKeyRead:
return APIKeyRead(
id=api_key.id,
provider=api_key.provider,
key_value_masked=self._mask_key(
try_decrypt(api_key.key_value) or api_key.key_value
),
key_meta=api_key.key_meta,
is_enabled=api_key.is_enabled,
usage_counter=api_key.usage_counter,
last_used_at=api_key.last_used_at,
created_at=api_key.created_at,
updated_at=api_key.updated_at,
meta=API_PROVIDER_META.get(api_key.provider, {}),
)
async def list_keys(self) -> list[APIKeyRead]:
result = await self.session.execute(select(APIKey).order_by(APIKey.provider))
return [self._to_read(k) for k in result.scalars().all()]
async def list_providers(self) -> list[ProviderInfo]:
result = await self.session.execute(select(APIKey))
configured = {k.provider: k for k in result.scalars().all()}
providers = []
for provider, meta in API_PROVIDER_META.items():
key = configured.get(provider)
providers.append(
ProviderInfo(
provider=provider,
name=meta["name"],
description=meta["description"],
docs_url=meta["docs_url"],
icon=meta.get("icon", "package"),
requires_username=meta.get("requires_username", False),
configured=key is not None,
is_enabled=key.is_enabled if key else False,
)
)
mode = await self._instance_mode()
return [p for p in providers if provider_allowed(mode, p.provider.value)]
async def create_key(self, data: APIKeyCreate) -> APIKeyRead:
mode = await self._instance_mode()
if not provider_allowed(mode, data.provider.value):
raise _http_error(
HTTPStatus.FORBIDDEN,
f"{data.provider.value} requires Bug bounty mode.",
)
if API_PROVIDER_META.get(data.provider, {}).get("requires_username"):
username = (data.key_meta or {}).get("username")
if not username or not str(username).strip():
raise _http_error(
HTTPStatus.BAD_REQUEST,
f"{data.provider.value} requires a username.",
)
existing = await self.session.execute(
select(APIKey).where(
APIKey.provider == data.provider,
)
)
if existing.scalar_one_or_none():
raise _http_error(
HTTPStatus.CONFLICT,
f"An API key for {data.provider.value} exists.",
)
api_key = APIKey(
provider=data.provider,
key_value=encrypt_secret(data.key_value),
key_meta=data.key_meta,
)
self.session.add(api_key)
try:
await self.session.commit()
except IntegrityError as e:
await self.session.rollback()
raise _http_error(
HTTPStatus.CONFLICT,
f"An API key for {data.provider.value} exists.",
) from e
await self.session.refresh(api_key)
return self._to_read(api_key)
async def update_key(self, key_id: str, data: APIKeyUpdate) -> APIKeyRead:
api_key = await self._get_key_or_404(key_id)
if data.key_value is not None:
api_key.key_value = encrypt_secret(data.key_value)
if data.is_enabled is not None:
api_key.is_enabled = data.is_enabled
if data.key_meta is not None:
api_key.key_meta = data.key_meta
api_key.updated_at = utc_now()
self.session.add(api_key)
await self.session.commit()
await self.session.refresh(api_key)
return self._to_read(api_key)
async def delete_key(self, key_id: str) -> None:
api_key = await self._get_key_or_404(key_id)
await self.session.delete(api_key)
await self.session.commit()
async def get_key_for_provider(self, provider: APIProvider) -> str | None:
result = await self.session.execute(
select(APIKey).where(
APIKey.provider == provider,
APIKey.is_enabled.is_(True),
)
)
api_key = result.scalar_one_or_none()
if not api_key:
return None
return try_decrypt(api_key.key_value) or api_key.key_value
async def increment_usage(self, provider: APIProvider) -> None:
result = await self.session.execute(
select(APIKey).where(
APIKey.provider == provider,
)
)
api_key = result.scalar_one_or_none()
if api_key:
api_key.usage_counter += 1
api_key.last_used_at = utc_now()
self.session.add(api_key)
await self.session.commit()
async def disable_key(self, provider: APIProvider) -> None:
result = await self.session.execute(
select(APIKey).where(
APIKey.provider == provider,
)
)
api_key = result.scalar_one_or_none()
if api_key:
api_key.is_enabled = False
api_key.updated_at = utc_now()
self.session.add(api_key)
await self.session.commit()
async def _get_key_or_404(self, key_id: str) -> APIKey:
try:
uuid_id = uuid.UUID(key_id)
except ValueError as e:
raise _http_error(HTTPStatus.BAD_REQUEST, "Invalid key ID") from e
result = await self.session.execute(select(APIKey).where(APIKey.id == uuid_id))
api_key = result.scalar_one_or_none()
if not api_key:
raise _http_error(HTTPStatus.NOT_FOUND, "API key not found")
return api_key