Files
Unraid-AppOps-Gateway-Public/backend/app/services/ai_security_reviewer.py
T

1018 lines
57 KiB
Python

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