from __future__ import annotations from collections import Counter from concurrent.futures import ThreadPoolExecutor from datetime import UTC, datetime import hashlib import json import os from time import monotonic from typing import Any, Callable from sqlalchemy import and_, func, not_ from sqlmodel import Session, select from ..connectors.authentik import AuthentikConnector from ..connectors.nginx_proxy_manager import NginxProxyManagerConnector from ..connectors.unraid import UnraidConnector from ..core.config import settings from ..core.redaction import redact_message from ..core.time import utc_now from ..db.models import App, AppSetting, AuditEvent, Connector, DiscoveredContainer, DiscoveryRun, Policy, Secret from ..schemas.ai_security import ( SecurityEvidence, SecurityFinding, SecurityRecommendation, SecurityReviewResult, SecurityReviewScope, SecurityReviewSummary, SecuritySuggestedAction, ) from .audit_logger import log_event from .policy_engine import evaluate_policy from .secret_store import list_secret_status, secret_storage_status SOURCE_NAMES = ('inventory', 'portainer', 'unraid', 'npm', 'authentik', 'secret_storage', 'policies', 'audit', 'media_pipeline') SEVERITY_PENALTY = {'critical': 20, 'high': 12, 'medium': 6, 'low': 2, 'info': 0} MUTATING_TOKENS = ('delete', 'remove', 'prune', 'format', 'stop', 'redeploy', 'create', 'update', 'rotate', 'sync', 'mass_edit') COVERAGE_CATEGORIES = {'coverage', 'connector_config'} _REVIEW_CACHE_TTL_SECONDS = 30 _SNAPSHOT_MAX_WORKERS = 10 _PERSISTENT_CACHE_SCHEMA_VERSION = 2 _PERSISTENT_CACHE_PREFIX = 'ai_security:review_cache' _SOURCE_SIGNATURE_CACHE_KEYS = {'media_pipeline:summary_cache', 'network_exposure:cache', 'safety_center:dashboard_cache'} _PERSISTENT_CACHE_MAX_STALE_SECONDS = 300 _REVIEW_CACHE: dict[str, tuple[float, SecurityReviewResult]] = {} OPTIONAL_CONNECTED_SECRET_REFS = { 'widefrog': {'WIDEFROG_API_KEY'}, } def _now() -> str: return datetime.now(UTC).isoformat() def _review_cache_key(session: Session, scope: SecurityReviewScope) -> str: try: bind = session.get_bind() db_key = str(getattr(bind, 'url', 'default')) except Exception: # noqa: BLE001 db_key = 'default' return f'{db_key}:{scope}' def _parse_utc(value: str) -> datetime: return datetime.fromisoformat(value.replace('Z', '+00:00')) def _persistent_cache_ttl_seconds() -> int: raw = os.getenv('AI_SECURITY_CACHE_TTL_SECONDS', str(_REVIEW_CACHE_TTL_SECONDS)) try: return max(1, int(raw)) except (TypeError, ValueError): return _REVIEW_CACHE_TTL_SECONDS def _persistent_cache_max_stale_seconds() -> int: raw = os.getenv('AI_SECURITY_PERSISTENT_CACHE_MAX_STALE_SECONDS', str(_PERSISTENT_CACHE_MAX_STALE_SECONDS)) try: return max(0, int(raw)) except (TypeError, ValueError): return _PERSISTENT_CACHE_MAX_STALE_SECONDS def _source_signature(session: Session, scope: SecurityReviewScope) -> str: from .app_registry import list_active_apps policies = [ {'id': item.id, 'action': item.action, 'target': item.target, 'voice': item.voice, 'manual': item.manual, 'risk': item.risk} for item in session.exec(select(Policy)).all() ] connectors = [ {'id': item.id, 'status': item.status, 'lastTest': item.last_test, 'secretRef': item.secret_ref, 'capabilities': item.capabilities} for item in session.exec(select(Connector)).all() ] apps = [ { 'id': item.id, 'status': item.status, 'connectorStatus': item.connector_status, 'containerStatus': item.container_status, 'needsReview': item.needs_review, 'discoveryStatus': item.discovery_status, 'protected': item.protected, } for item in list_active_apps(session) ] settings = [ { 'key': item.key, 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, 'isSecret': item.is_secret, 'status': item.value_json.get('status') if isinstance(item.value_json, dict) else None, } for item in session.exec(select(AppSetting)).all() if not _is_source_signature_cache_key(item.key) and (not item.is_secret or item.key.startswith('ai_security:')) ] secret_rows = [ {'id': item.id, 'name': item.name, 'updatedAt': item.updated_at.isoformat() if item.updated_at else None, 'usedBy': item.used_by_json} for item in session.exec(select(Secret)).all() ] discovered_count = session.exec(select(func.count(DiscoveredContainer.id))).one() # Failed automatic startup reconciliation is operational bookkeeping, not # a changed security source. Keeping it in the signature invalidates the # durable review on every service restart even when nothing was discovered. failed_startup_reconciliation = and_( AuditEvent.source == 'system', AuditEvent.identity == 'appops-startup', AuditEvent.action_id.in_({'deployment.target.enroll', 'deployment.state.reconcile'}), AuditEvent.result == 'failed', ) non_ai_audit = and_( AuditEvent.action_id.not_like('ai.security.%'), not_(failed_startup_reconciliation), ) audit_count = session.exec(select(func.count(AuditEvent.id)).where(non_ai_audit)).one() latest_audit_timestamp = session.exec(select(func.max(AuditEvent.timestamp)).where(non_ai_audit)).one() signature_payload = { 'cacheSchemaVersion': _PERSISTENT_CACHE_SCHEMA_VERSION, 'scope': scope, 'policies': sorted(policies, key=lambda item: item['id']), 'connectors': sorted(connectors, key=lambda item: item['id']), 'apps': sorted(apps, key=lambda item: item['id']), 'settings': sorted(settings, key=lambda item: item['key']), 'secrets': sorted(secret_rows, key=lambda item: item['id']), 'counts': { 'discovered': discovered_count, 'audit': audit_count, 'latestAuditTimestamp': latest_audit_timestamp.isoformat() if latest_audit_timestamp else None, }, } serialized = json.dumps(signature_payload, sort_keys=True, default=str, separators=(',', ':')) return hashlib.sha256(serialized.encode('utf-8')).hexdigest() def _persistent_cache_key(scope: SecurityReviewScope) -> str: return f'{_PERSISTENT_CACHE_PREFIX}:{scope}' def _is_source_signature_cache_key(key: str) -> bool: return ( key.startswith(_PERSISTENT_CACHE_PREFIX) or key.startswith('observability:') or key in _SOURCE_SIGNATURE_CACHE_KEYS or key.endswith(':cache') or key.endswith('_cache') ) def _persistent_review(session: Session, scope: SecurityReviewScope, signature: str) -> SecurityReviewResult | None: row = session.get(AppSetting, _persistent_cache_key(scope)) if not row or not isinstance(row.value_json, dict): return None value = row.value_json if value.get('schemaVersion') != _PERSISTENT_CACHE_SCHEMA_VERSION or value.get('signature') != signature: return None payload = value.get('payload') expires_at_raw = value.get('expiresAt') if not isinstance(payload, dict) or not isinstance(expires_at_raw, str): return None expires_at = _parse_utc(expires_at_raw) now_dt = datetime.now(UTC) stale_until = expires_at.timestamp() + _persistent_cache_max_stale_seconds() if now_dt >= expires_at and now_dt.timestamp() >= stale_until: return None try: return SecurityReviewResult.model_validate(payload).model_copy(deep=True) except Exception: # noqa: BLE001 return None def _store_persistent_review(session: Session, scope: SecurityReviewScope, signature: str, result: SecurityReviewResult) -> None: ttl_seconds = _persistent_cache_ttl_seconds() expires_at = datetime.fromtimestamp(datetime.now(UTC).replace(microsecond=0).timestamp() + ttl_seconds, UTC).isoformat().replace('+00:00', 'Z') row = session.get(AppSetting, _persistent_cache_key(scope)) if not row: row = AppSetting(key=_persistent_cache_key(scope), is_secret=False) row.value_json = { 'schemaVersion': _PERSISTENT_CACHE_SCHEMA_VERSION, 'signature': signature, 'payload': _redact(result.model_dump()), 'generatedAt': result.summary.generatedAt, 'expiresAt': expires_at, } row.updated_at = utc_now() session.add(row) session.commit() def _cached_review(session: Session, scope: SecurityReviewScope) -> SecurityReviewResult | None: key = _review_cache_key(session, scope) entry = _REVIEW_CACHE.get(key) if entry: created_at, result = entry if monotonic() - created_at <= _REVIEW_CACHE_TTL_SECONDS: return result.model_copy(deep=True) _REVIEW_CACHE.pop(key, None) signature = _source_signature(session, scope) persisted = _persistent_review(session, scope, signature) if persisted: _REVIEW_CACHE[key] = (monotonic(), persisted.model_copy(deep=True)) return persisted return None def _store_review(session: Session, scope: SecurityReviewScope, result: SecurityReviewResult) -> SecurityReviewResult: _REVIEW_CACHE[_review_cache_key(session, scope)] = (monotonic(), result.model_copy(deep=True)) _store_persistent_review(session, scope, _source_signature(session, scope), result) return result def _clear_review_cache() -> None: _REVIEW_CACHE.clear() def _safe(call: Callable[[], Any], status: str = 'unavailable') -> dict[str, Any]: try: payload = call() if isinstance(payload, dict): return payload return {'success': True, 'status': 'ok', 'items': payload if isinstance(payload, list) else []} except Exception as exc: # noqa: BLE001 - reviewer must degrade instead of crash. return {'success': False, 'status': status, 'message': redact_message(str(exc))} def _redact(value: Any) -> Any: if isinstance(value, str): lower_value = value.lower() if any(token in lower_value for token in ('token_value', 'secret_value', 'api_key=', 'password=')): return '[REDACTED]' return redact_message(value) if isinstance(value, list): return [_redact(item) for item in value] if isinstance(value, dict): return {str(key): _redact(item) for key, item in value.items() if 'secret' not in str(key).lower() and 'token' not in str(key).lower()} return value def _redact_mapping_values(value: dict[str, Any]) -> dict[str, Any]: return {str(key): _redact(item) for key, item in value.items()} def _setting_key(kind: str, item_id: str) -> str: return f'ai_security:{kind}:{item_id}:status' def _connector_setup_href(connector_id: str) -> str: return f'/setup?step=connectors&connector={connector_id}' def _connector_setup_href_for_secret(session: Session, secret_ref: str) -> str: connector = session.exec(select(Connector).where(Connector.secret_ref == secret_ref)).first() if connector: return _connector_setup_href(str(connector.id)) return '/secrets' def _npm_snapshot() -> dict[str, Any]: connector = NginxProxyManagerConnector() return { 'npmExposure': connector.exposure_summary(), 'npmHosts': connector.proxy_hosts(), } def _authentik_snapshot() -> dict[str, Any]: connector = AuthentikConnector() return { 'authCoverage': connector.auth_coverage(), 'authWarnings': connector.security_warnings(), 'authOutpost': connector.outpost_health(), } def _get_state(session: Session, kind: str, item_id: str, default: str = 'open') -> str: row = session.get(AppSetting, _setting_key(kind, item_id)) if not row: return default return str(row.value_json.get('status') or default) def _set_state(session: Session, kind: str, item_id: str, status: str) -> dict[str, Any]: key = _setting_key(kind, item_id) row = session.get(AppSetting, key) if not row: row = AppSetting(key=key, is_secret=False) row.value_json = {'status': status, 'updatedAt': _now()} row.updated_at = utc_now() session.add(row) session.commit() log_event( session, source='manual', identity='ai_security_reviewer', action_id=f'ai.security.{kind}.{status}', target=item_id, target_type=kind, risk='low', policy_decision='allowed', reason=f'AI Security Reviewer {kind} marked {status}.', result='success', redacted_metadata={'kind': kind, 'id': item_id, 'status': status}, ) return {'success': True, 'id': item_id, 'status': status} def _action( session: Session, label: str, *, action_type: str = 'open_page', href: str | None = None, action_id: str | None = None, target: str | None = None, source: str = 'manual', ) -> SecuritySuggestedAction: policy = {'decision': 'allowed', 'risk': 'low'} if action_id and target: policy = evaluate_policy(session, action=action_id, target=target, source=source) return SecuritySuggestedAction( label=label, type=action_type, # type: ignore[arg-type] actionId=action_id, target=target, policyDecision=str(policy.get('decision', 'allowed')), risk=str(policy.get('risk', 'low')), href=href, executable=False, ) def _evidence(source: str, label: str, value: Any = None, *, observed: Any = None, expected: Any = None, reference_id: str | None = None) -> SecurityEvidence: redacted = _redact(value) redacted_observed = _redact(observed) return SecurityEvidence( source=source, label=label, value=redacted, observed=redacted_observed, expected=_redact(expected), referenceId=reference_id, redacted=redacted != value or redacted_observed != observed, ) def _finding( session: Session, finding_id: str, *, title: str, description: str, severity: str, confidence: str, category: str, affected: list[str], evidence: list[SecurityEvidence], risk_reason: str, actions: list[SecuritySuggestedAction], ) -> SecurityFinding: return SecurityFinding( id=finding_id, title=str(_redact(title)), description=str(_redact(description)), severity=severity, # type: ignore[arg-type] confidence=confidence, # type: ignore[arg-type] category=category, # type: ignore[arg-type] affectedResources=[str(_redact(item)) for item in affected], evidence=evidence, suggestedActions=actions, status=_get_state(session, 'finding', finding_id), # type: ignore[arg-type] createdAt=_now(), riskReason=str(_redact(risk_reason)), links=[{'label': action.label, 'href': action.href} for action in actions if action.href], ) def _recommendation(session: Session, rec_id: str, finding: SecurityFinding, label: str) -> SecurityRecommendation: return SecurityRecommendation( id=rec_id, title=f'Review: {finding.title}', description=finding.description, severity=finding.severity, confidence=finding.confidence, evidence=finding.evidence, affectedResources=finding.affectedResources, suggestedAction=label, suggestedActions=finding.suggestedActions, policyDecision=finding.suggestedActions[0].policyDecision if finding.suggestedActions else 'allowed', actionTarget=finding.affectedResources[0] if finding.affectedResources else None, canAutoApply=False, status=_get_state(session, 'recommendation', rec_id), # type: ignore[arg-type] ) def _latest_discovered_containers(session: Session) -> list[DiscoveredContainer]: from .app_registry import is_appops_deployment_container_name runs = {run.id: run for run in session.exec(select(DiscoveryRun)).all()} latest: dict[str, tuple[datetime, DiscoveredContainer]] = {} for item in session.exec(select(DiscoveredContainer)).all(): run = runs.get(item.discovery_run_id) observed_at = run.started_at if run else datetime.min current = latest.get(item.container_id) if not current or observed_at >= current[0]: latest[item.container_id] = (observed_at, item) return [ item for _, item in latest.values() if not is_appops_deployment_container_name(item.name) ] def _canonical_app_for_discovery(item: DiscoveredContainer, apps_by_id: dict[str, App]) -> App | None: candidate_ids = [ str(item.matched_app_id or ''), str(item.suggested_app_type or ''), str(item.suggested_connector or ''), str(item.suggested_app_type or '').replace('-', '_'), str(item.suggested_connector or '').replace('-', '_'), str(item.name or '').strip().lower().replace(' ', '_').replace('-', '_'), ] for candidate_id in candidate_ids: if candidate_id and candidate_id in apps_by_id: return apps_by_id[candidate_id] normalized_name = str(item.name or '').strip().lower().replace(' ', '').replace('-', '').replace('_', '') for app in apps_by_id.values(): app_name = str(app.name or '').strip().lower().replace(' ', '').replace('-', '').replace('_', '') if normalized_name and app_name == normalized_name: return app return None def _discovery_row_needs_review(item: DiscoveredContainer, apps_by_id: dict[str, App]) -> bool: canonical = _canonical_app_for_discovery(item, apps_by_id) if canonical and not canonical.needs_review: return False return True def _snapshot(session: Session) -> dict[str, Any]: from .app_registry import list_active_apps try: from . import media_pipeline_service def media_summary() -> dict[str, Any]: try: return media_pipeline_service.summary(session) except TypeError: return media_pipeline_service.summary() except Exception as exc: # noqa: BLE001 media_error = redact_message(str(exc)) def media_summary() -> dict[str, Any]: return {'success': False, 'status': 'unavailable', 'message': media_error} external_calls: dict[str, Callable[[], Any]] = { 'npmSnapshot': _npm_snapshot, 'authentikSnapshot': _authentik_snapshot, 'unraidConnection': lambda: UnraidConnector().test_connection(), 'unraidArray': lambda: UnraidConnector().get_array_status(), 'unraidCache': lambda: UnraidConnector().get_cache_status(), 'unraidDisks': lambda: UnraidConnector().get_disk_warnings(), 'unraidDocker': lambda: UnraidConnector().get_docker_service_status(), 'mediaPipeline': media_summary, } with ThreadPoolExecutor(max_workers=min(_SNAPSHOT_MAX_WORKERS, len(external_calls))) as executor: futures = {key: executor.submit(_safe, call) for key, call in external_calls.items()} external = {key: future.result() for key, future in futures.items()} npm_snapshot = external.pop('npmSnapshot') if {'npmExposure', 'npmHosts'}.issubset(npm_snapshot.keys()): external.update(npm_snapshot) else: external['npmExposure'] = npm_snapshot external['npmHosts'] = npm_snapshot authentik_snapshot = external.pop('authentikSnapshot') if {'authCoverage', 'authWarnings', 'authOutpost'}.issubset(authentik_snapshot.keys()): external.update(authentik_snapshot) else: external['authCoverage'] = authentik_snapshot external['authWarnings'] = authentik_snapshot external['authOutpost'] = authentik_snapshot return { 'apps': list_active_apps(session), 'connectors': session.exec(select(Connector)).all(), 'discovered': _latest_discovered_containers(session), 'policies': session.exec(select(Policy)).all(), 'audit': session.exec(select(AuditEvent).order_by(AuditEvent.timestamp.desc())).all(), 'secretStorage': _safe(lambda: secret_storage_status()), 'secrets': _safe(lambda: {'success': True, 'status': 'ok', 'items': list_secret_status(session)}), **external, } def _source_connected(payload: Any) -> bool: if isinstance(payload, dict): if payload.get('status') in {'missing_config', 'unavailable', 'unsupported', 'error'}: return False return bool(payload.get('success', True)) return bool(payload) def _payload_status(payload: Any) -> str: if not isinstance(payload, dict): return 'connected' if payload else 'unavailable' status = str(payload.get('status') or ('connected' if payload.get('success', True) else 'unavailable')) if status in {'ok', 'success'}: return 'connected' if status in {'connected', 'missing_config', 'partial', 'unavailable', 'unsupported', 'error', 'warning', 'critical'}: return status if payload.get('success') is False: return 'unavailable' return 'connected' def _connector_status(data: dict[str, Any], connector_id: str) -> str: for connector in data.get('connectors', []): if getattr(connector, 'id', None) == connector_id: return str(getattr(connector, 'status', 'unavailable') or 'unavailable') return 'unavailable' def _coverage_source(status: str, *, effect: str) -> dict[str, Any]: return {'status': status, 'effectOnConfidence': effect} def _coverage_sources(data: dict[str, Any]) -> dict[str, dict[str, Any]]: npm_statuses = {_payload_status(data['npmExposure']), _payload_status(data['npmHosts'])} auth_statuses = {_payload_status(data['authCoverage']), _payload_status(data['authWarnings'])} unraid_status = _payload_status(data['unraidConnection']) media_status = _payload_status(data['mediaPipeline']) secret_status = _payload_status(data['secretStorage']) portainer_status = _connector_status(data, 'portainer') def combined(statuses: set[str]) -> str: if 'connected' in statuses: return 'connected' if 'partial' in statuses: return 'partial' if 'missing_config' in statuses: return 'missing_config' if 'warning' in statuses: return 'partial' return 'unavailable' return { 'inventory': _coverage_source('connected' if data['apps'] or data['discovered'] else 'unavailable', effect='high'), 'portainer': _coverage_source('connected' if portainer_status == 'connected' else _connector_status(data, 'portainer'), effect='high'), 'unraid': _coverage_source(unraid_status, effect='medium'), 'npm': _coverage_source(combined(npm_statuses), effect='high'), 'authentik': _coverage_source(combined(auth_statuses), effect='high'), 'secret_storage': _coverage_source('connected' if _source_connected(data['secretStorage']) else secret_status, effect='high'), 'policies': _coverage_source('connected' if data['policies'] else 'unavailable', effect='high'), 'audit': _coverage_source('connected' if data['audit'] else 'partial', effect='medium'), 'media_pipeline': _coverage_source('connected' if media_status == 'connected' else media_status, effect='medium'), } def _data_completeness(data: dict[str, Any]) -> dict[str, Any]: coverage = _coverage_sources(data) connected = [name for name, detail in coverage.items() if detail['status'] == 'connected'] missing = [name for name, detail in coverage.items() if detail['status'] in {'missing_config', 'unavailable', 'unsupported', 'error'}] return {'percentage': round((len(connected) / len(SOURCE_NAMES)) * 100), 'connectedSources': connected, 'missingSources': missing} def _coverage_status(completeness: dict[str, Any], *, high: int, critical: int, proven_high_risk: bool) -> str: if critical or proven_high_risk: return 'critical' if completeness['missingSources']: return 'warning' if high else 'partial' if completeness['percentage'] < 100: return 'partial' return 'healthy' def _exposure_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: payload = data['npmExposure'] findings: list[SecurityFinding] = [] if payload.get('status') == 'missing_config': findings.append(_finding(session, 'ai-sec-npm-missing-config', title='Coverage incomplete: NPM connector is not configured', description='Security review is incomplete because NPM is not configured; public exposure cannot be concluded from missing coverage alone.', severity='medium', confidence='high', category='coverage', affected=['nginx_proxy_manager'], evidence=[_evidence('npm', 'status', payload.get('status'))], risk_reason='Coverage incomplete; no public exposure finding is raised without NPM host evidence.', actions=[_action(session, 'Configure NPM connector', href='/connectors'), _action(session, 'Open Secrets Vault', href='/secrets'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) return findings warnings = payload.get('warnings') if isinstance(payload.get('warnings'), list) else [] mapping = { 'host_without_certificate': ('ai-sec-npm-host-without-certificate', 'NPM public host without certificate', 'high'), 'certificate_expired': ('ai-sec-npm-certificate-expired', 'NPM certificate expired', 'critical'), 'certificate_expiring_soon': ('ai-sec-npm-certificate-expiring-soon', 'NPM certificate expiring soon', 'medium'), 'public_host_without_access_list': ('ai-sec-npm-host-without-access-list', 'NPM public host without access list', 'high'), 'ssl_not_forced': ('ai-sec-npm-host-without-ssl-force', 'NPM host does not force SSL', 'medium'), 'unknown_proxy_target': ('ai-sec-npm-unknown-target', 'NPM host forwards to unknown target', 'medium'), } seen: set[str] = set() auth_coverage = data.get('authCoverage') if isinstance(data.get('authCoverage'), dict) else {} unprotected_auth_domains = _unprotected_auth_domains(auth_coverage) for warning in warnings: if not isinstance(warning, dict): continue warning_type = str(warning.get('type')) if warning_type not in mapping or warning_type in seen: continue if warning_type == 'public_host_without_access_list' and unprotected_auth_domains is not None: continue seen.add(warning_type) finding_id, title, severity = mapping[warning_type] findings.append(_finding(session, finding_id, title=title, description=str(warning.get('title') or title), severity=severity, confidence='high', category='exposure', affected=_npm_warning_affected_resources(warning_type, warning), evidence=[_evidence('npm', warning_type, warning.get('evidence'), reference_id=warning_type)], risk_reason='NPM exposure warning reported by connector.', actions=[_action(session, 'Open Exposure Center', href='/network'), _action(session, 'Create review task', action_type='policy_draft', target='nginx_proxy_manager')])) return findings def _npm_warning_affected_resources(warning_type: str, warning: dict[str, Any]) -> list[str]: evidence = [str(item) for item in warning.get('evidence', []) if str(item).strip()] if warning_type in {'certificate_expired', 'certificate_expiring_soon', 'ssl_not_forced'} and evidence: return [evidence[0]] return evidence[:3] def _warning_domains(warning: dict[str, Any]) -> set[str]: domains: set[str] = set() for item in warning.get('evidence', []): for candidate in str(item).replace(',', ' ').split(): value = candidate.strip().lower() if '.' in value: domains.add(value) return domains def _unprotected_auth_domains(coverage: dict[str, Any]) -> set[str] | None: if coverage.get('status') != 'ok': return None public_hosts = coverage.get('publicHosts') if not isinstance(public_hosts, list): return None domains: set[str] = set() for host in public_hosts: if not isinstance(host, dict) or host.get('coverageStatus') != 'unprotected': continue for domain in host.get('domains', []): value = str(domain).strip().lower() if value: domains.add(value) return domains def _auth_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: findings: list[SecurityFinding] = [] coverage = data['authCoverage'] npm_hosts_payload = data['npmHosts'] hosts = npm_hosts_payload.get('items') if isinstance(npm_hosts_payload.get('items'), list) else [] if coverage.get('status') == 'missing_config' and hosts: findings.append(_finding(session, 'ai-sec-authentik-missing-public-hosts', title='Authentik coverage unavailable for public NPM hosts', description='NPM reports public hosts, but Authentik coverage cannot be reviewed because Authentik is missing configuration.', severity='high', confidence='high', category='auth_coverage', affected=['authentik', 'nginx_proxy_manager'], evidence=[_evidence('authentik', 'status', coverage.get('status')), _evidence('npm', 'publicHosts', len(hosts), expected='0 or mapped')], risk_reason='Public host evidence exists, but identity coverage is unavailable.', actions=[_action(session, 'Configure Authentik connector', href='/connectors'), _action(session, 'Open Exposure Center', href='/network'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) elif coverage.get('status') == 'missing_config' and _payload_status(npm_hosts_payload) == 'missing_config': findings.append(_finding(session, 'ai-sec-authentik-coverage-missing-config', title='Coverage incomplete: Authentik connector is not configured', description='Security review is incomplete because Authentik and NPM coverage are unavailable; unprotected public hosts are not inferred.', severity='medium', confidence='high', category='coverage', affected=['authentik'], evidence=[_evidence('authentik', 'status', coverage.get('status')), _evidence('npm', 'status', npm_hosts_payload.get('status'))], risk_reason='Auth coverage unavailable; no public-host protection conclusion is made without NPM host evidence.', actions=[_action(session, 'Configure Authentik connector', href='/connectors'), _action(session, 'Open Secrets Vault', href='/secrets'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) unprotected_hosts = [ host for host in coverage.get('publicHosts', []) if isinstance(host, dict) and host.get('coverageStatus') == 'unprotected' ] if isinstance(coverage.get('publicHosts'), list) else [] if unprotected_hosts: domains = [ str(domain) for host in unprotected_hosts for domain in host.get('domains', []) if str(domain).strip() ] sample = domains[:10] findings.append(_finding(session, 'ai-sec-auth-public-host-without-authentik', title='Public hosts without Authentik mapping', description=f"{len(domains) or len(unprotected_hosts)} public host(s) have no Authentik mapping.", severity='high', confidence='high', category='auth_coverage', affected=sample, evidence=[_evidence('authentik', 'unprotectedHosts', len(unprotected_hosts), expected='0', reference_id='auth_coverage')], risk_reason='One or more public hosts appear unprotected by identity provider.', actions=[_action(session, 'Open Exposure Center', href='/network'), _action(session, 'Open Authentik details', href='/apps/authentik')])) outpost = data['authOutpost'] reported_outposts = outpost.get('items') if isinstance(outpost.get('items'), list) else [] # Authentik does not expose a reliable health value for every embedded # outpost version. A reported outpost with unknown telemetry is therefore # not evidence that the outpost is unavailable. if outpost.get('embeddedExpected') and not outpost.get('available') and not reported_outposts: outpost_status = str(outpost.get('status') or 'unknown') severity = 'medium' if outpost_status in {'warning', 'unknown'} else 'high' findings.append(_finding(session, 'ai-sec-auth-outpost-unavailable', title='Expected Authentik outpost unavailable', description='Embedded outpost is expected but unavailable or unknown.', severity=severity, confidence='medium', category='auth_coverage', affected=['authentik'], evidence=[_evidence('authentik', 'outpostAvailable', outpost.get('available'), expected=True), _evidence('authentik', 'outpostStatus', outpost_status)], risk_reason='Auth proxy enforcement may need review.', actions=[_action(session, 'Open Authentik details', href='/apps/authentik')])) for warning in data['authWarnings'].get('items', []) if isinstance(data['authWarnings'].get('items'), list) else []: if 'TOKEN_VALUE' in str(warning) or 'SECRET' in str(warning): findings.append(_finding(session, 'ai-sec-auth-warning-redacted', title=str(warning.get('title', 'Authentik warning')), description='Authentik warning included sensitive-looking material and was redacted.', severity=str(warning.get('severity', 'medium')), confidence='medium', category='auth_coverage', affected=['authentik'], evidence=[_evidence('authentik', 'warning', warning.get('evidence'))], risk_reason='Connector warning needs review.', actions=[_action(session, 'Open Authentik details', href='/apps/authentik')])) return findings def _secret_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: findings: list[SecurityFinding] = [] storage = data['secretStorage'] if settings.environment == 'production' and not settings.secret_key: findings.append(_finding(session, 'ai-sec-secret-key-missing-production', title='Production secret key missing', description='Production should set APPOPS_SECRET_KEY or APPOPS_SECRET_KEY_FILE.', severity='critical', confidence='high', category='secrets', affected=['secret_storage'], evidence=[_evidence('secrets', 'keySource', settings.secret_key_source, expected='env or file')], risk_reason='Encrypted connector secrets need stable production key.', actions=[_action(session, 'Open Secrets Vault', href='/secrets')])) if storage.get('encryptedAtRest') is False: findings.append(_finding(session, 'ai-sec-secret-storage-not-encrypted', title='Secret storage not encrypted', description='Secret storage reports encryptedAtRest=false.', severity='critical', confidence='high', category='secrets', affected=['secret_storage'], evidence=[_evidence('secrets', 'encryptedAtRest', False, expected=True)], risk_reason='Connector secrets must be encrypted at rest.', actions=[_action(session, 'Open Secrets Vault', href='/secrets')])) secret_items = data['secrets'].get('items', []) if isinstance(data['secrets'].get('items'), list) else [] connected_optional_refs = { secret_ref for connector in data['connectors'] if getattr(connector, 'status', None) == 'connected' for secret_ref in OPTIONAL_CONNECTED_SECRET_REFS.get(str(connector.id), set()) } for item in secret_items: if not item.get('hasSecret'): ref = str(item.get('secretRef') or item.get('name') or 'connector_secret') if ref in connected_optional_refs: continue setup_href = _connector_setup_href_for_secret(session, ref) findings.append(_finding(session, f'ai-sec-missing-secret-{ref.lower()}', title=f'Coverage incomplete: missing connector secret {ref}', description='A connector secret reference is missing, so connector coverage is incomplete.', severity='medium', confidence='high', category='connector_config', affected=[ref], evidence=[_evidence('secret_storage', 'secretStatus', item.get('status'), expected='configured', reference_id=ref)], risk_reason='Connector cannot be fully validated without its secret.', actions=[_action(session, 'Open connector setup', href=setup_href), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) break if item.get('status') == 'env_fallback': ref = str(item.get('secretRef') or item.get('name') or 'connector_secret') findings.append(_finding(session, f'ai-sec-secret-env-fallback-{ref.lower()}', title=f'Secret uses env fallback: {ref}', description='A connector secret is available through environment fallback instead of stored secret metadata.', severity='info', confidence='medium', category='secrets', affected=[ref], evidence=[_evidence('secrets', 'status', item.get('status'), expected='configured', reference_id=ref)], risk_reason='Env fallback is supported but less visible to setup lifecycle.', actions=[_action(session, 'Open Secrets Vault', href='/secrets')])) break if not any(finding.id.startswith('ai-sec-missing-secret-') for finding in findings): known = {str(item.get('secretRef') or item.get('name') or ''): item for item in secret_items} for connector in data['connectors']: secret_ref = getattr(connector, 'secret_ref', None) if not secret_ref: continue if ( getattr(connector, 'status', None) == 'connected' and secret_ref in OPTIONAL_CONNECTED_SECRET_REFS.get(str(connector.id), set()) ): continue status = known.get(secret_ref) if status and status.get('hasSecret'): continue if not settings.apis.get(secret_ref): setup_href = _connector_setup_href(str(connector.id)) findings.append(_finding(session, f'ai-sec-missing-secret-{secret_ref.lower()}', title=f'Coverage incomplete: missing connector secret {secret_ref}', description='A connector secret reference is configured but no stored or environment-backed value is available.', severity='medium', confidence='high', category='connector_config', affected=[secret_ref], evidence=[_evidence('secret_storage', 'connectorSecretRef', secret_ref, observed='missing', expected='configured')], risk_reason='Connector cannot be fully validated without its secret.', actions=[_action(session, 'Open connector setup', href=setup_href), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) break return findings def _policy_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: findings: list[SecurityFinding] = [] policy_by_action = {policy.action: policy for policy in data['policies']} destructive = ['volume.delete', 'docker.prune', 'unraid.disk.format'] for action_id in destructive: policy = policy_by_action.get(action_id) if policy and (policy.voice != 'blocked' or policy.manual != 'blocked'): findings.append(_finding(session, f'ai-sec-policy-{action_id}-not-blocked'.replace('.', '-'), title=f'Destructive action not blocked: {action_id}', description='A destructive action is not blocked for all sources.', severity='critical', confidence='high', category='policies', affected=[action_id], evidence=[_evidence('policies', action_id, {'voice': policy.voice, 'manual': policy.manual}, expected='blocked')], risk_reason='Destructive action should fail closed.', actions=[_action(session, 'Open Policies', href='/policies')])) mover = policy_by_action.get('unraid.mover.start') if mover and mover.voice == 'allowed': findings.append(_finding(session, 'ai-sec-policy-unraid-mover-voice-allowed', title='Unraid mover voice action is allowed', description='unraid.mover.start should not be voice-allowed by default.', severity='high', confidence='high', category='policies', affected=['unraid.mover.start'], evidence=[_evidence('policies', 'voice', mover.voice, expected='blocked or approval_required', reference_id=mover.id)], risk_reason='Mover can affect storage state and should be operator-controlled.', actions=[_action(session, 'Open Policies', href='/policies'), _action(session, 'Create policy draft', action_type='policy_draft', action_id='unraid.mover.start', target='unraid')])) for policy in data['policies']: if policy.voice == 'allowed' and policy.risk in {'high', 'critical'}: findings.append(_finding(session, f'ai-sec-policy-high-risk-voice-{policy.id}', title=f'High-risk voice action allowed: {policy.action}', description='High-risk actions should require approval or be blocked for voice.', severity='high', confidence='medium', category='policies', affected=[policy.action, policy.target], evidence=[_evidence('policies', 'voice', policy.voice, expected='approval_required or blocked', reference_id=policy.id)], risk_reason='Voice action could bypass operator review.', actions=[_action(session, 'Open Policies', href='/policies')])) break return findings def _connector_inventory_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: findings: list[SecurityFinding] = [] apps_by_id = {str(app.id): app for app in data['apps']} for connector in data['connectors']: if connector.id in {'portainer', 'unraid', 'nginx_proxy_manager', 'authentik'} and connector.status in {'missing_config', 'disconnected', 'error'}: findings.append(_finding(session, f'ai-sec-connector-{connector.id}-not-ready', title=f'Coverage incomplete: {connector.name} connector not ready', description='An important connector is not connected or configured.', severity='medium', confidence='high', category='connector_config', affected=[connector.id], evidence=[_evidence('connectors', 'status', connector.status, expected='connected', reference_id=connector.id)], risk_reason='Reviewer coverage is incomplete while connector is not ready.', actions=[_action(session, 'Open Connector Settings', href='/connectors'), _action(session, 'Open Secrets Vault', href='/secrets'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) break for item in data['discovered']: matched_app = apps_by_id.get(str(item.matched_app_id or '')) if item.import_status == 'changed' and matched_app and matched_app.protected and not matched_app.needs_review: continue if item.import_status in {'discovered', 'unmapped', 'changed'} and item.suggested_criticality in {'high', 'critical'} and _discovery_row_needs_review(item, apps_by_id): href = f'/apps/{item.matched_app_id}' if item.matched_app_id else '/inventory' findings.append(_finding(session, f'ai-sec-inventory-critical-review-{item.id}', title=f'Critical discovered app needs review: {item.name}', description='A critical-looking discovered app still needs review or mapping.', severity='high', confidence='medium', category='inventory', affected=[item.name], evidence=[_evidence('inventory', 'importStatus', item.import_status, expected='managed', reference_id=item.id)], risk_reason='Critical app should be reviewed before relying on automation.', actions=[_action(session, 'Open App detail' if item.matched_app_id else 'Open Inventory', href=href)])) break for app in data['apps']: if app.criticality == 'critical' and not app.protected: findings.append(_finding(session, f'ai-sec-critical-app-unprotected-{app.id}', title=f'Critical app is not protected: {app.name}', description='Critical app registry entry is not marked protected.', severity='high', confidence='medium', category='inventory', affected=[app.id], evidence=[_evidence('inventory', 'protected', app.protected, expected=True, reference_id=app.id)], risk_reason='Critical apps should default to protected controls.', actions=[_action(session, 'Open App detail', href=f'/apps/{app.id}')])) break if app.needs_review and app.voice_enabled: findings.append(_finding(session, f'ai-sec-discovered-voice-before-review-{app.id}', title=f'Voice enabled before review: {app.name}', description='Discovered apps should not have voice enabled before review.', severity='critical', confidence='high', category='inventory', affected=[app.id], evidence=[_evidence('inventory', 'voiceEnabled', app.voice_enabled, expected=False, reference_id=app.id)], risk_reason='Voice automation before review violates safety defaults.', actions=[_action(session, 'Open App detail', href=f'/apps/{app.id}')])) break return findings def _audit_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: events = data['audit'] if not events: return [_finding(session, 'ai-sec-audit-no-recent-events', title='No recent audit events', description='No audit events are available for reviewer analysis.', severity='low', confidence='medium', category='audit', affected=['audit'], evidence=[_evidence('audit', 'events', 0, expected='>0')], risk_reason='Audit history improves security review confidence.', actions=[_action(session, 'Open Audit Log', href='/audit')])] resolved_setup_checks: set[str] = set() blocked: list[AuditEvent] = [] for event in events: action_target = f'{event.action_id}:{event.target}' if event.action_id.startswith('setup.test.') and event.result == 'success' and event.policy_decision != 'blocked': resolved_setup_checks.add(action_target) continue if event.policy_decision == 'blocked' or event.result == 'failed': if event.action_id.startswith('setup.test.') and action_target in resolved_setup_checks: continue blocked.append(event) if len(blocked) >= 3: counter = Counter(f'{event.action_id}:{event.target}' for event in blocked) action_target, count = counter.most_common(1)[0] return [_finding(session, 'ai-sec-audit-repeated-blocked-attempts', title='Repeated blocked or failed attempts', description='Repeated blocked/failed audit events were detected.', severity='medium', confidence='high', category='audit', affected=[action_target], evidence=[_evidence('audit', 'blockedOrFailedCount', count, expected='<3')], risk_reason='Repeated blocked actions may indicate noisy automation or probing.', actions=[_action(session, 'Open Audit Log', href='/audit')])] return [] def _unraid_media_findings(session: Session, data: dict[str, Any]) -> list[SecurityFinding]: findings: list[SecurityFinding] = [] if data['unraidConnection'].get('status') == 'missing_config': findings.append(_finding(session, 'ai-sec-unraid-missing-config', title='Coverage incomplete: Unraid connector is not configured', description='Unraid safety data is unavailable because the connector is missing configuration.', severity='medium', confidence='high', category='coverage', affected=['unraid'], evidence=[_evidence('unraid', 'status', 'missing_config', expected='connected')], risk_reason='Server safety checks are incomplete; no Unraid risk is concluded from missing coverage alone.', actions=[_action(session, 'Configure Unraid connector', href='/connectors'), _action(session, 'Open Secrets Vault', href='/secrets'), _action(session, 'Re-run Security Review', href='/ai/security-reviewer')])) if data['unraidCache'].get('cacheUsage', 0) and int(data['unraidCache'].get('cacheUsage', 0)) >= 85: findings.append(_finding(session, 'ai-sec-unraid-cache-high', title='Unraid cache usage high', description='Cache usage is above recommended threshold.', severity='medium', confidence='medium', category='unraid', affected=['unraid-cache'], evidence=[_evidence('unraid', 'cacheUsage', data['unraidCache'].get('cacheUsage'), expected='<85')], risk_reason='High cache usage can affect Docker/appdata stability.', actions=[_action(session, 'Open Storage', href='/storage')])) media = data['mediaPipeline'] if media.get('status') in {'partial', 'warning', 'critical'}: issues = media.get('issues') if isinstance(media.get('issues'), list) else [] issue_titles = [str(issue.get('title')) for issue in issues if isinstance(issue, dict) and issue.get('title')] affected = [ str(item) for issue in issues if isinstance(issue, dict) for item in (issue.get('affected') if isinstance(issue.get('affected'), list) else []) if str(item).strip() ] if not affected: counts = media.get('counts') if isinstance(media.get('counts'), dict) else {} risk = media.get('risk') if isinstance(media.get('risk'), dict) else {} reasons = ' '.join(str(item) for item in risk.get('reasons', []) if item) if int(counts.get('stalledDownloads') or 0) > 0 or 'deluge' in reasons.lower(): affected.append('deluge') if int(counts.get('failedWidefrogJobs') or 0) > 0 or 'widefrog' in reasons.lower(): affected.append('widefrog') if int(counts.get('failingIndexers') or 0) > 0 or 'prowlarr' in reasons.lower(): affected.append('prowlarr') if int(counts.get('transcodes') or 0) >= 3 or 'plex' in reasons.lower(): affected.append('plex') affected = list(dict.fromkeys(affected))[:6] or ['media_pipeline'] issue_evidence = issue_titles[:5] if not issue_evidence: counts = media.get('counts') if isinstance(media.get('counts'), dict) else {} risk = media.get('risk') if isinstance(media.get('risk'), dict) else {} issue_evidence = [str(item) for item in risk.get('reasons', []) if item] if not issue_evidence: issue_evidence = [f'{key}={value}' for key, value in counts.items() if value] title = 'Media pipeline needs review' if len(issue_titles) == 1: title = f'{title}: {issue_titles[0]}' findings.append(_finding(session, 'ai-sec-media-pipeline-issue', title=title, description=str(media.get('message') or '; '.join(issue_titles[:3]) or 'Media pipeline reported a warning or partial state.'), severity='medium', confidence='medium', category='media', affected=affected, evidence=[_evidence('media_pipeline', 'status', media.get('status'), expected='healthy'), _evidence('media_pipeline', 'issues', issue_evidence or None)], risk_reason='Media connector issues can hide operational failures.', actions=[_action(session, 'Open Media Pipeline', href='/media-pipeline'), _action(session, 'Run media pipeline diagnose', action_type='run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline')])) return findings def _finding_has_proven_high_risk(finding: SecurityFinding) -> bool: if finding.status != 'open': return False if finding.severity == 'critical': return True if finding.severity != 'high': return False if finding.category in COVERAGE_CATEGORIES: return False high_risk_tokens = ( 'without-certificate', 'certificate-expired', 'without-access-list', 'public-host', 'not-blocked', 'voice-allowed', 'high-risk-voice', 'secret-key-missing', 'secret-storage-not-encrypted', 'critical-app-unprotected', 'voice-before-review', ) return any(token in finding.id for token in high_risk_tokens) def _build_score_breakdown(data: dict[str, Any], findings: list[SecurityFinding], score: int, counts: dict[str, int], coverage_status: str) -> dict[str, Any]: completeness = _data_completeness(data) coverage_sources = _coverage_sources(data) penalties: list[dict[str, Any]] = [] for finding in findings: if finding.status != 'open': continue points = SEVERITY_PENALTY.get(finding.severity, 0) if points <= 0: continue source = finding.category evidence_source = finding.evidence[0].source if finding.evidence else source penalties.append({ 'source': evidence_source, 'reason': finding.title, 'severity': finding.severity, 'points': points, 'findingId': finding.id, }) return { 'baseScore': 100, 'finalScore': score, 'penalties': _redact(penalties), 'missingSources': completeness['missingSources'], 'dataCompleteness': completeness, 'criticalFindingsCount': counts['critical'], 'highFindingsCount': counts['high'], 'mediumFindingsCount': counts['medium'], 'coverageStatus': coverage_status, 'coverageSources': _redact_mapping_values(coverage_sources), } def generate_review(session: Session, scope: SecurityReviewScope = 'all', audit_run: bool = False) -> SecurityReviewResult: if not audit_run: cached = _cached_review(session, scope) if cached: return cached data = _snapshot(session) rules = { 'exposure': _exposure_findings, 'auth': _auth_findings, 'secrets': _secret_findings, 'policies': _policy_findings, 'connectors': _connector_inventory_findings, 'audit': _audit_findings, 'unraid': _unraid_media_findings, 'media': _unraid_media_findings, } selected = list(rules) if scope == 'all' else [scope] findings: list[SecurityFinding] = [] for key in selected: rule = rules.get(key) if rule: findings.extend(rule(session, data)) deduped = list({finding.id: finding for finding in findings}.values()) recommendations = [_recommendation(session, f'rec-{finding.id}', finding, finding.suggestedActions[0].label if finding.suggestedActions else 'Review finding') for finding in deduped if finding.severity in {'critical', 'high', 'medium'}] score = max(0, min(100, 100 - sum(SEVERITY_PENALTY.get(finding.severity, 0) for finding in deduped if finding.status == 'open'))) critical = sum(1 for finding in deduped if finding.severity == 'critical' and finding.status == 'open') high = sum(1 for finding in deduped if finding.severity == 'high' and finding.status == 'open') medium = sum(1 for finding in deduped if finding.severity == 'medium' and finding.status == 'open') warnings = sum(1 for finding in deduped if finding.severity in {'low', 'medium', 'high'} and finding.status == 'open') info = sum(1 for finding in deduped if finding.severity == 'info' and finding.status == 'open') completeness = _data_completeness(data) proven_high_risk = any(_finding_has_proven_high_risk(finding) for finding in deduped) real_high = any(finding.status == 'open' and finding.severity == 'high' and finding.category not in COVERAGE_CATEGORIES for finding in deduped) if critical > 0 or (score < 65 and (real_high or proven_high_risk)): status = 'critical' elif completeness['missingSources']: status = 'warning' if high or proven_high_risk else 'partial' elif score < 85 or high or medium: status = 'warning' else: status = 'healthy' coverage_status = _coverage_status(completeness, high=high, critical=critical, proven_high_risk=proven_high_risk) score_breakdown = _build_score_breakdown( data, deduped, score, {'critical': critical, 'high': high, 'medium': medium}, coverage_status, ) summary = SecurityReviewSummary( status=status, # type: ignore[arg-type] score=score, generatedAt=_now(), dataCompleteness=completeness, criticalFindings=critical, highFindings=high, mediumFindings=medium, warningFindings=warnings, infoFindings=info, recommendationsCount=len(recommendations), connectedSources=completeness['connectedSources'], scoreBreakdown=score_breakdown, ) if audit_run: log_event(session, source='manual', identity='ai_security_reviewer', action_id='ai.security.review', target=str(scope), target_type='ai_security', risk='low', policy_decision='allowed', reason='AI Security Reviewer read-only review generated.', result='success', redacted_metadata={'scope': scope, 'score': score, 'findings': len(deduped)}) result = SecurityReviewResult(summary=summary, findings=deduped, recommendations=recommendations) return _store_review(session, scope, result) def get_summary(session: Session) -> dict[str, Any]: return generate_review(session).summary.model_dump() def get_findings(session: Session, scope: SecurityReviewScope = 'all') -> list[dict[str, Any]]: return [finding.model_dump() for finding in generate_review(session, scope).findings] def get_finding(session: Session, finding_id: str) -> dict[str, Any] | None: return next((finding for finding in get_findings(session) if finding['id'] == finding_id), None) def get_recommendations(session: Session) -> list[dict[str, Any]]: return [recommendation.model_dump() for recommendation in generate_review(session).recommendations] def set_finding_status(session: Session, finding_id: str, status: str) -> dict[str, Any]: _clear_review_cache() result = _set_state(session, 'finding', finding_id, status) finding = get_finding(session, finding_id) result['finding'] = finding return result def set_recommendation_status(session: Session, recommendation_id: str, status: str) -> dict[str, Any]: _clear_review_cache() result = _set_state(session, 'recommendation', recommendation_id, status) recommendation = next((item for item in get_recommendations(session) if item['id'] == recommendation_id), None) result['recommendation'] = recommendation result['applied'] = False return result