Files
rengine/api/app/services/instance_settings.py
T
Yogesh OjhaandClaude Opus 5 f3b406ec03 feat(freshness): re-check a certificate between scans, which is Class B's first job
The derivation rule names four classes and only three had anything implementing
them. Class B is "needs a per-asset request, so a background job with its own
freshness, never a stage that blocks", and a certificate is the clearest case:
it expires and renews on a schedule no scan is aligned to. A dashboard built on
a scan from six weeks ago reports every certificate that has auto-renewed since
as expiring or expired — 548 hosts on this box sit inside a 30-day window right
now, and on a 90-day ACME cycle every one of them would be wrong within a month.

shared/services/cert_freshness.py + app.tasks.freshness.certificates (beat, 4h).
The policy is the design: a certificate whose stored expiry is more than 30 days
away is re-asked after 12h, one already inside its renewal window after 4h,
nearest expiry first, 500 a pass, one tlsx handshake per host. Only the newest
scan of each target is refreshed — an older scan's rows are a record of that
run, not a claim about today. A host that did not answer keeps what the scan
found and still gets its stamp moved: silence is not evidence the certificate
changed, but an unreachable host must not be re-asked every four hours.

It is opt-in, not opt-out (instance_settings.cert_recheck_enabled, default
false, a switch in Settings). The threat feeds only download; this one
handshakes the operator's targets outside a scan, and nobody should discover
that after the fact.

Two tool bugs, both the shape CLAUDE.md already lists four of. CLIToolRunner.run
defaults to OutputFormat.PLAIN and only appends json_flag for JSONL, so passing
json_flag without the format runs the tool with no -json at all — the first live
pass answered 0 of 40. And tlsx writes every record it is going to write and
then does not exit (40 hosts: 16s to emit, still running at 90s), so the run is
bounded by budget(hosts) and Freshness.cut_short reports a pass that hit the
ceiling instead of swallowing it. Together: 300.2s and 0 answers became 15.6s
and 37 of 40.

19 tests, each proven to fail against 9 mutations of the policy.

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

231 lines
9.3 KiB
Python

import asyncio
from zoneinfo import ZoneInfo
import httpx
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
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.enums.instance import AIProvider
from shared.http import get_async_client
from shared.models.instance_settings import (
InstanceSettings,
InstanceSettingsRead,
InstanceSettingsUpdate,
)
from shared.services.scan_resolve import MASK
from shared.utils.datetime import utc_now
from shared.utils.net import validate_public_https_url
_SINGLETON_KEY = "instance"
_TEST_TIMEOUT = 10
_OPENAI_BASE = "https://api.openai.com"
_ANTHROPIC_BASE = "https://api.anthropic.com"
_ANTHROPIC_VERSION = "2023-06-01"
_ANTHROPIC_PING_MODEL = "claude-haiku-4-5"
_GOOGLE_BASE = "https://generativelanguage.googleapis.com"
_HTTP_OK = 200
_HTTP_UNAUTHORIZED = 401
_VALID_AI_PROVIDERS = frozenset(p.value for p in AIProvider)
def _mask_ai_key(key: str) -> str:
if len(key) <= 4: # noqa: PLR2004
return MASK
return f"{MASK}{key[-4:]}"
def _validate_public_https_url(raw: str) -> str:
validate_public_https_url(raw, label="Azure endpoint")
return raw
class InstanceSettingsService:
def __init__(self, session: AsyncSession):
self.session = session
async def get_or_create(self) -> InstanceSettings:
result = await self.session.execute(
select(InstanceSettings).where(
InstanceSettings.singleton_key == _SINGLETON_KEY
)
)
settings = result.scalar_one_or_none()
if settings is None:
settings = InstanceSettings(singleton_key=_SINGLETON_KEY)
self.session.add(settings)
try:
await self.session.commit()
await self.session.refresh(settings)
except IntegrityError:
await self.session.rollback()
result = await self.session.execute(
select(InstanceSettings).where(
InstanceSettings.singleton_key == _SINGLETON_KEY
)
)
settings = result.scalar_one()
return settings
def to_read(self, settings: InstanceSettings) -> InstanceSettingsRead:
key = try_decrypt(settings.ai_api_key_encrypted)
return InstanceSettingsRead(
id=settings.id,
singleton_key=settings.singleton_key,
instance_name=settings.instance_name,
timezone=settings.timezone,
mode=settings.mode,
onboarding_completed=settings.onboarding_completed,
onboarding_completed_at=settings.onboarding_completed_at,
onboarding_completed_by=settings.onboarding_completed_by,
onboarding_step=settings.onboarding_step,
onboarding_state=settings.onboarding_state or {},
scan_history_retention_days=settings.scan_history_retention_days,
screenshot_retention_days=settings.screenshot_retention_days,
ai_enabled=settings.ai_enabled,
ai_provider=settings.ai_provider,
ai_model=settings.ai_model,
ai_api_key_masked=_mask_ai_key(key) if key else None,
ai_configured=bool(settings.ai_api_key_encrypted),
ai_features=settings.ai_features or {},
capabilities=capabilities_for(settings.mode),
created_at=settings.created_at,
updated_at=settings.updated_at,
)
async def update(self, data: InstanceSettingsUpdate) -> InstanceSettingsRead:
settings = await self.get_or_create()
if data.instance_name is not None:
settings.instance_name = data.instance_name
if data.timezone is not None:
try:
ZoneInfo(data.timezone)
except Exception as exc:
msg = f"Invalid timezone '{data.timezone}'."
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=msg
) from exc
settings.timezone = data.timezone
if data.mode is not None:
if data.mode not in VALID_MODES:
msg = f"Invalid mode '{data.mode}'. Must be one of {', '.join(sorted(VALID_MODES))}."
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
settings.mode = data.mode
if data.scan_history_retention_days is not None:
settings.scan_history_retention_days = data.scan_history_retention_days
if data.screenshot_retention_days is not None:
settings.screenshot_retention_days = data.screenshot_retention_days
if data.cert_recheck_enabled is not None:
settings.cert_recheck_enabled = data.cert_recheck_enabled
if data.ai_enabled is not None:
settings.ai_enabled = data.ai_enabled
if data.ai_provider is not None:
if data.ai_provider not in _VALID_AI_PROVIDERS:
msg = (
f"Invalid ai_provider '{data.ai_provider}'. "
f"Must be one of {', '.join(sorted(_VALID_AI_PROVIDERS))}."
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=msg)
settings.ai_provider = data.ai_provider
if data.ai_model is not None:
settings.ai_model = data.ai_model
if data.ai_features is not None:
settings.ai_features = dict(data.ai_features)
if data.ai_api_key is not None and data.ai_api_key != MASK:
settings.ai_api_key_encrypted = (
encrypt_secret(data.ai_api_key) if data.ai_api_key else None
)
settings.updated_at = utc_now()
await self.session.commit()
await self.session.refresh(settings)
return self.to_read(settings)
async def test_ai(
self, provider: str, model: str | None, api_key: str | None
) -> dict:
if not api_key or api_key == MASK:
api_key = try_decrypt((await self.get_or_create()).ai_api_key_encrypted)
if not api_key:
return {"success": False, "message": "No API key provided."}
try:
return await self._ping(provider, model, api_key)
except (httpx.HTTPError, ValueError, KeyError) as exc:
return {"success": False, "message": f"Connection failed: {exc}"}
async def _ping(self, provider: str, model: str | None, api_key: str) -> dict:
async with get_async_client(timeout=_TEST_TIMEOUT) as client:
if provider == AIProvider.OPENAI.value:
return await self._ping_openai(client, api_key)
if provider == AIProvider.ANTHROPIC.value:
return await self._ping_anthropic(client, api_key)
if provider == AIProvider.GOOGLE.value:
return await self._ping_google(client, api_key)
if provider == AIProvider.AZURE_OPENAI.value:
return await self._ping_azure(client, model, api_key)
return {"success": False, "message": f"Unsupported provider '{provider}'."}
async def _ping_openai(self, client: httpx.AsyncClient, api_key: str) -> dict:
resp = await client.get(
f"{_OPENAI_BASE}/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
)
return self._result(resp, "OpenAI")
async def _ping_anthropic(self, client: httpx.AsyncClient, api_key: str) -> dict:
resp = await client.post(
f"{_ANTHROPIC_BASE}/v1/messages",
headers={
"x-api-key": api_key,
"anthropic-version": _ANTHROPIC_VERSION,
},
json={
"model": _ANTHROPIC_PING_MODEL,
"max_tokens": 1,
"messages": [{"role": "user", "content": "ping"}],
},
)
return self._result(resp, "Anthropic")
async def _ping_google(self, client: httpx.AsyncClient, api_key: str) -> dict:
resp = await client.get(
f"{_GOOGLE_BASE}/v1beta/models",
params={"key": api_key},
)
return self._result(resp, "Google")
async def _ping_azure(
self, client: httpx.AsyncClient, model: str | None, api_key: str
) -> dict:
if not model:
return {
"success": False,
"message": "Azure OpenAI requires an endpoint URL in the model field.",
}
url = await asyncio.to_thread(_validate_public_https_url, model.rstrip("/"))
resp = await client.get(
url,
headers={"api-key": api_key},
follow_redirects=False,
)
ok = resp.status_code != _HTTP_UNAUTHORIZED
if ok:
return {"success": True, "message": "Azure OpenAI reachable."}
return {"success": False, "message": "Azure OpenAI authentication failed."}
def _result(self, resp: httpx.Response, name: str) -> dict:
if resp.status_code == _HTTP_OK:
return {"success": True, "message": f"{name} connection successful."}
if resp.status_code == _HTTP_UNAUTHORIZED:
return {"success": False, "message": f"{name} authentication failed."}
return {
"success": False,
"message": f"{name} returned status {resp.status_code}.",
}