858 lines
34 KiB
Python
858 lines
34 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
from datetime import UTC, datetime
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
from threading import Lock, Thread
|
|
from time import perf_counter
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func
|
|
from sqlmodel import Session, select
|
|
|
|
from ..core.redaction import redact_message
|
|
from ..core.time import utc_now
|
|
from ..db.models import App, AppSetting, AuditEvent, Connector, DiscoveryRun, JobRun, Policy, Secret
|
|
from . import ai_remediation_planner, ai_security_reviewer
|
|
from .exposure_preferences import get_exposure_preferences
|
|
from .audit_logger import log_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DEFAULT_CACHE_TTL_SECONDS = 60
|
|
CACHE_KEY = 'safety-dashboard-v1'
|
|
CACHE_SCHEMA_VERSION = 3
|
|
CACHE_SETTING_KEY = 'safety_center:dashboard_cache'
|
|
DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS = 900
|
|
|
|
_dashboard_cache: dict[str, Any] | None = None
|
|
_skip_persistent_cache_once = False
|
|
_background_refresh_lock = Lock()
|
|
_background_refresh_running = False
|
|
|
|
|
|
SECTION_DEFINITIONS = [
|
|
('setup-coverage', 'Setup & Coverage', '/setup', {'coverage', 'connector_config', 'setup'}),
|
|
('external-exposure', 'External Exposure', '/network', {'exposure'}),
|
|
('authentication-coverage', 'Authentication Coverage', '/apps/authentik', {'auth'}),
|
|
('secret-hygiene', 'Secret Hygiene', '/secrets', {'secrets'}),
|
|
('policy-posture', 'Policy Posture', '/policies', {'policies'}),
|
|
('inventory-app-review', 'Inventory & App Review', '/inventory', {'inventory', 'connectors'}),
|
|
('media-pipeline', 'Media Pipeline', '/media-pipeline', {'media'}),
|
|
('unraid-host-safety', 'Unraid & Host Safety', '/unraid', {'unraid'}),
|
|
('audit-activity', 'Audit & Activity', '/audit', {'audit'}),
|
|
]
|
|
|
|
SEVERITY_PENALTY = {'critical': 20, 'high': 12, 'medium': 6, 'low': 2, 'info': 0}
|
|
CATEGORY_MAP = {
|
|
'auth_coverage': 'auth',
|
|
'coverage': 'setup',
|
|
'connector_config': 'connectors',
|
|
}
|
|
SOURCE_LABELS = {
|
|
'inventory': 'Inventory',
|
|
'portainer': 'Portainer',
|
|
'unraid': 'Unraid',
|
|
'npm': 'NPM',
|
|
'authentik': 'Authentik',
|
|
'secret_storage': 'Secret Storage',
|
|
'policies': 'Policies',
|
|
'audit': 'Audit',
|
|
'media_pipeline': 'Media Pipeline',
|
|
}
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(UTC).replace(microsecond=0).isoformat().replace('+00:00', 'Z')
|
|
|
|
|
|
def _parse_utc(value: str) -> datetime:
|
|
return datetime.fromisoformat(value.replace('Z', '+00:00'))
|
|
|
|
|
|
def _ttl_seconds() -> int:
|
|
raw = os.getenv('SAFETY_CACHE_TTL_SECONDS', str(DEFAULT_CACHE_TTL_SECONDS))
|
|
try:
|
|
return max(1, int(raw))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_CACHE_TTL_SECONDS
|
|
|
|
|
|
def _persistent_cache_max_stale_seconds() -> int:
|
|
raw = os.getenv('SAFETY_PERSISTENT_CACHE_MAX_STALE_SECONDS', str(DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS))
|
|
try:
|
|
return max(0, int(raw))
|
|
except (TypeError, ValueError):
|
|
return DEFAULT_PERSISTENT_CACHE_MAX_STALE_SECONDS
|
|
|
|
|
|
def invalidate_cache() -> None:
|
|
global _dashboard_cache, _skip_persistent_cache_once
|
|
_dashboard_cache = None
|
|
_skip_persistent_cache_once = True
|
|
|
|
|
|
def _redact(value: Any) -> Any:
|
|
if isinstance(value, str):
|
|
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 _state_key(kind: str, item_id: str) -> str:
|
|
return f'safety_center:{kind}:{item_id}:status'
|
|
|
|
|
|
def _get_state(session: Session, kind: str, item_id: str, default: str = 'open') -> str:
|
|
row = session.get(AppSetting, _state_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]:
|
|
row = session.get(AppSetting, _state_key(kind, item_id))
|
|
if not row:
|
|
row = AppSetting(key=_state_key(kind, item_id), 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='safety_center',
|
|
action_id=f'safety.{kind}.{status}',
|
|
target=item_id,
|
|
target_type=kind,
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason=f'Safety Center {kind} marked {status}.',
|
|
result='success',
|
|
redacted_metadata={'kind': kind, 'id': item_id, 'status': status},
|
|
)
|
|
invalidate_cache()
|
|
return {'success': True, 'id': item_id, 'status': status}
|
|
|
|
|
|
def _category(category: str) -> str:
|
|
return CATEGORY_MAP.get(category, category)
|
|
|
|
|
|
def _action_type(raw: str | None) -> str:
|
|
allowed = {
|
|
'open_page',
|
|
'configure_connector',
|
|
'configure_secret',
|
|
'run_review',
|
|
'create_remediation_plan',
|
|
'run_readonly_check',
|
|
'review_policy',
|
|
}
|
|
if raw == 'run_readonly_check':
|
|
return raw
|
|
if raw in {'policy_draft', 'review_policy'}:
|
|
return 'review_policy'
|
|
if raw == 'configure_secret':
|
|
return raw
|
|
if raw == 'configure_connector':
|
|
return raw
|
|
return 'open_page' if raw in {None, 'link'} else (raw if raw in allowed else 'open_page')
|
|
|
|
|
|
def _suggested_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
result: list[dict[str, Any]] = []
|
|
for action in actions:
|
|
label = str(action.get('label') or action.get('type') or 'Open details')
|
|
href = action.get('href') or action.get('link')
|
|
result.append({
|
|
'label': _redact(label),
|
|
'actionType': _action_type(action.get('type')),
|
|
'href': href or '/safety',
|
|
'policyDecision': action.get('policyDecision'),
|
|
'executable': False,
|
|
})
|
|
return result
|
|
|
|
|
|
def _findings(session: Session, review: Any) -> list[dict[str, Any]]:
|
|
findings: list[dict[str, Any]] = []
|
|
for finding in review.findings:
|
|
source_status = _get_state(session, 'finding', finding.id, finding.status)
|
|
category = _category(finding.category)
|
|
findings.append({
|
|
'id': finding.id,
|
|
'title': _redact(finding.title),
|
|
'description': _redact(finding.description),
|
|
'severity': finding.severity,
|
|
'category': category,
|
|
'affectedResources': _redact(finding.affectedResources),
|
|
'evidence': _redact([item.model_dump() for item in finding.evidence]),
|
|
'suggestedActions': _suggested_actions([item.model_dump() for item in finding.suggestedActions]),
|
|
'source': 'AI Security Reviewer',
|
|
'status': source_status,
|
|
})
|
|
return findings
|
|
|
|
|
|
def _coverage(session: Session, summary: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], list[str], list[str], dict[str, Any]]:
|
|
breakdown = summary.get('scoreBreakdown') if isinstance(summary.get('scoreBreakdown'), dict) else {}
|
|
sources = breakdown.get('coverageSources') if isinstance(breakdown.get('coverageSources'), dict) else {}
|
|
if not sources:
|
|
sources = {
|
|
'inventory': {'status': 'connected'},
|
|
'portainer': {'status': _connector_status(session, 'portainer')},
|
|
'unraid': {'status': _connector_status(session, 'unraid')},
|
|
'npm': {'status': _connector_status(session, 'nginx_proxy_manager')},
|
|
'authentik': {'status': _connector_status(session, 'authentik')},
|
|
'secret_storage': {'status': 'connected'},
|
|
'policies': {'status': 'connected'},
|
|
'audit': {'status': 'partial'},
|
|
'media_pipeline': {'status': 'partial'},
|
|
}
|
|
normalized = {
|
|
key: {'label': SOURCE_LABELS.get(key, key), 'status': str(value.get('status', 'unknown')), 'effectOnConfidence': value.get('effectOnConfidence', 'medium')}
|
|
for key, value in sources.items()
|
|
if isinstance(value, dict)
|
|
}
|
|
connected = [key for key, value in normalized.items() if value['status'] == 'connected']
|
|
missing = [key for key, value in normalized.items() if value['status'] in {'missing_config', 'unavailable', 'unsupported', 'error', 'unknown'}]
|
|
completeness_weight = sum(_coverage_weight(value['status']) for value in normalized.values())
|
|
completeness = {'percentage': round((completeness_weight / max(len(normalized), 1)) * 100), 'connectedSources': connected, 'missingSources': missing}
|
|
return normalized, connected, missing, completeness
|
|
|
|
|
|
def _connector_status(session: Session, connector_id: str) -> str:
|
|
connector = session.get(Connector, connector_id)
|
|
return str(connector.status if connector else 'unknown')
|
|
|
|
|
|
def _coverage_weight(status: str) -> float:
|
|
if status == 'connected':
|
|
return 1.0
|
|
if status == 'partial':
|
|
return 0.5
|
|
return 0.0
|
|
|
|
|
|
def _counts(findings: list[dict[str, Any]]) -> Counter:
|
|
return Counter(finding['severity'] for finding in findings if finding['status'] == 'open')
|
|
|
|
|
|
def _has_real_high_or_critical(findings: list[dict[str, Any]]) -> bool:
|
|
proven_high_risk_categories = {'auth', 'auth_coverage', 'exposure', 'policies', 'secrets', 'unraid'}
|
|
return any(
|
|
finding['status'] == 'open'
|
|
and finding['severity'] in {'high', 'critical'}
|
|
and finding['category'] in proven_high_risk_categories
|
|
for finding in findings
|
|
)
|
|
|
|
|
|
def _status(score: int, counts: Counter, missing: list[str], findings: list[dict[str, Any]]) -> str:
|
|
if counts['critical'] > 0:
|
|
return 'critical'
|
|
if missing:
|
|
return 'warning' if counts['high'] > 0 and _has_real_high_or_critical(findings) else 'partial'
|
|
if score < 85 or counts['medium'] or counts['high']:
|
|
return 'warning'
|
|
return 'healthy'
|
|
|
|
|
|
def _section_status(score: int, counts: Counter, missing_only: bool) -> str:
|
|
if counts['critical'] > 0:
|
|
return 'critical'
|
|
if missing_only and counts['high'] == 0:
|
|
return 'partial'
|
|
if score < 85 or counts['medium'] or counts['high']:
|
|
return 'warning'
|
|
return 'healthy'
|
|
|
|
|
|
def _section_for_category(category: str) -> str:
|
|
for section_id, _, _, categories in SECTION_DEFINITIONS:
|
|
if category in categories:
|
|
return section_id
|
|
return 'setup-coverage'
|
|
|
|
|
|
def _section_summary(section_id: str, section_findings: list[dict[str, Any]]) -> str:
|
|
if not section_findings:
|
|
return 'Geen open rule-based findings voor deze sectie.'
|
|
top = section_findings[0]
|
|
return str(top['title'])
|
|
|
|
|
|
def _sections(findings: list[dict[str, Any]], recommendations: list[dict[str, Any]], coverage: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
result = []
|
|
for section_id, title, href, categories in SECTION_DEFINITIONS:
|
|
section_findings = [
|
|
finding
|
|
for finding in findings
|
|
if finding['status'] == 'open' and _section_for_category(finding['category']) == section_id
|
|
]
|
|
section_recommendations = [item for item in recommendations if item['sectionId'] == section_id and item['status'] == 'open']
|
|
counts = _counts(section_findings)
|
|
score = max(0, 100 - sum(SEVERITY_PENALTY.get(finding['severity'], 0) for finding in section_findings))
|
|
related_sources = _related_sources(section_id)
|
|
source_weight = sum(_coverage_weight(str(coverage.get(source, {}).get('status', 'unknown'))) for source in related_sources)
|
|
missing_only = bool(related_sources) and source_weight < len(related_sources) and not any(finding['severity'] in {'high', 'critical'} for finding in section_findings)
|
|
completeness = round((source_weight / max(len(related_sources), 1)) * 100) if related_sources else 100
|
|
result.append({
|
|
'id': section_id,
|
|
'title': title,
|
|
'status': _section_status(score, counts, missing_only),
|
|
'score': score,
|
|
'summary': _section_summary(section_id, section_findings),
|
|
'findingsCount': len(section_findings),
|
|
'recommendationsCount': len(section_recommendations),
|
|
'dataCompleteness': completeness,
|
|
'href': href,
|
|
'categories': sorted(categories),
|
|
})
|
|
return result
|
|
|
|
|
|
def _related_sources(section_id: str) -> list[str]:
|
|
return {
|
|
'setup-coverage': ['inventory', 'portainer', 'secret_storage', 'policies'],
|
|
'external-exposure': ['npm'],
|
|
'authentication-coverage': ['authentik', 'npm'],
|
|
'secret-hygiene': ['secret_storage'],
|
|
'policy-posture': ['policies'],
|
|
'inventory-app-review': ['inventory', 'portainer'],
|
|
'media-pipeline': ['media_pipeline'],
|
|
'unraid-host-safety': ['unraid', 'portainer'],
|
|
'audit-activity': ['audit'],
|
|
}.get(section_id, [])
|
|
|
|
|
|
def _base_recommendations(session: Session, findings: list[dict[str, Any]], coverage: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
recommendations: list[dict[str, Any]] = []
|
|
for source, detail in coverage.items():
|
|
if detail['status'] not in {'missing_config', 'unavailable', 'unsupported', 'error', 'unknown'}:
|
|
continue
|
|
if source in {'npm', 'authentik', 'unraid'}:
|
|
recommendations.append(_recommendation(
|
|
rec_id=f'safety-rec-configure-{source}',
|
|
title=f'Configureer {SOURCE_LABELS.get(source, source)} connector',
|
|
description='Vul de connectorconfiguratie en secret in en draai daarna een read-only test.',
|
|
severity='medium',
|
|
confidence='high',
|
|
source='Setup & Coverage',
|
|
affected=[source],
|
|
action_type='configure_connector',
|
|
href='/setup',
|
|
section_id='setup-coverage',
|
|
))
|
|
for finding in findings:
|
|
if finding['severity'] not in {'critical', 'high', 'medium'}:
|
|
continue
|
|
section_id = _section_for_category(finding['category'])
|
|
action = finding['suggestedActions'][0] if finding['suggestedActions'] else {}
|
|
recommendations.append(_recommendation(
|
|
rec_id=f'safety-rec-{finding["id"]}',
|
|
title=f'Review: {finding["title"]}',
|
|
description=finding['description'],
|
|
severity=finding['severity'],
|
|
confidence='high',
|
|
source=section_id,
|
|
affected=finding['affectedResources'],
|
|
action_type=action.get('actionType') or 'open_page',
|
|
href=action.get('href') or '/safety',
|
|
section_id=section_id,
|
|
source_finding_id=finding['id'],
|
|
policy_decision=action.get('policyDecision'),
|
|
))
|
|
return list({item['id']: item for item in recommendations}.values())
|
|
|
|
|
|
def _recommendation(
|
|
*,
|
|
rec_id: str,
|
|
title: str,
|
|
description: str,
|
|
severity: str,
|
|
confidence: str,
|
|
source: str,
|
|
affected: list[str],
|
|
action_type: str,
|
|
href: str,
|
|
section_id: str,
|
|
source_finding_id: str | None = None,
|
|
policy_decision: str | None = None,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
'id': rec_id,
|
|
'title': _redact(title),
|
|
'description': _redact(description),
|
|
'severity': severity,
|
|
'confidence': confidence,
|
|
'source': source,
|
|
'affectedResources': _redact(affected),
|
|
'actionType': _action_type(action_type),
|
|
'href': href,
|
|
'policyDecision': policy_decision,
|
|
'executable': False,
|
|
'canCreateRemediationPlan': bool(source_finding_id),
|
|
'sourceFindingId': source_finding_id,
|
|
'sectionId': section_id,
|
|
'status': 'open',
|
|
}
|
|
|
|
|
|
def _add_local_findings(session: Session, findings: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
result = list(findings)
|
|
latest_run = session.exec(select(DiscoveryRun).order_by(DiscoveryRun.started_at.desc())).first()
|
|
if not latest_run:
|
|
result.append(_local_finding('safety-discovery-not-run', 'Container discovery is nog niet gedraaid', 'Discovery coverage ontbreekt; inventory review is daardoor partial.', 'medium', 'setup', ['discovery'], ['No discovery run found'], '/inventory'))
|
|
apps_need_review = session.exec(select(App).where(App.needs_review == True)).all() # noqa: E712
|
|
if apps_need_review:
|
|
href = f'/apps/{apps_need_review[0].id}' if len(apps_need_review) == 1 else '/inventory'
|
|
result.append(_local_finding('safety-apps-need-review', 'Apps wachten op review', f'{len(apps_need_review)} app(s) vragen operator review voordat bredere automation veilig is.', 'medium', 'inventory', [app.id for app in apps_need_review[:6]], ['needs_review=true'], href))
|
|
jobs = session.exec(select(JobRun).where(JobRun.status.in_(['queued', 'running']))).all()
|
|
if len(jobs) >= 5:
|
|
result.append(_local_finding('safety-many-open-jobs', 'Veel open jobs actief', f'{len(jobs)} jobs staan queued/running.', 'low', 'audit', ['jobs'], ['open jobs >= 5'], '/jobs'))
|
|
return list({item['id']: item for item in result}.values())
|
|
|
|
|
|
def _local_finding(finding_id: str, title: str, description: str, severity: str, category: str, affected: list[str], evidence: list[str], href: str) -> dict[str, Any]:
|
|
return {
|
|
'id': finding_id,
|
|
'title': title,
|
|
'description': description,
|
|
'severity': severity,
|
|
'category': category,
|
|
'affectedResources': affected,
|
|
'evidence': [{'source': 'safety_center', 'label': title, 'value': evidence, 'redacted': False}],
|
|
'suggestedActions': [{'label': 'Open details', 'actionType': 'open_page', 'href': href, 'policyDecision': 'allowed', 'executable': False}],
|
|
'source': 'Safety Center',
|
|
'status': 'open',
|
|
}
|
|
|
|
|
|
def _source_signature(session: Session) -> str:
|
|
from .app_registry import list_active_apps
|
|
from .inventory_discovery import latest_discovered_by_container
|
|
|
|
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 item.key != CACHE_SETTING_KEY
|
|
and not item.key.startswith('ai:chat:')
|
|
and item.key != 'ai:modelforge:last_inference'
|
|
and not item.key.startswith('ai_security:review_cache')
|
|
and item.key not in {'media_pipeline:summary_cache', 'network_exposure:cache'}
|
|
and (not item.is_secret or item.key.startswith('safety_center:'))
|
|
]
|
|
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 = [
|
|
{
|
|
'containerId': item.container_id,
|
|
'name': item.name,
|
|
'image': item.image,
|
|
'state': item.state,
|
|
'status': item.status,
|
|
'importStatus': item.import_status,
|
|
'matchedAppId': item.matched_app_id,
|
|
}
|
|
for item in latest_discovered_by_container(session).values()
|
|
]
|
|
job_count = session.exec(select(func.count(JobRun.id))).one()
|
|
latest_job_started = session.exec(select(func.max(JobRun.started_at))).one()
|
|
non_safety_audit = AuditEvent.action_id.not_like('safety.%')
|
|
audit_count = session.exec(select(func.count(AuditEvent.id)).where(non_safety_audit)).one()
|
|
latest_audit_timestamp = session.exec(select(func.max(AuditEvent.timestamp)).where(non_safety_audit)).one()
|
|
signature_payload = {
|
|
'cacheSchemaVersion': CACHE_SCHEMA_VERSION,
|
|
'exposurePreferences': get_exposure_preferences(session).model_dump(),
|
|
'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']),
|
|
'discovered': sorted(discovered, key=lambda item: item['containerId']),
|
|
'counts': {
|
|
'jobs': job_count,
|
|
'latestJobStarted': latest_job_started.isoformat() if latest_job_started else None,
|
|
'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 _payload(session: Session) -> dict[str, Any]:
|
|
review = ai_security_reviewer.generate_review(session)
|
|
security_summary = review.summary.model_dump()
|
|
coverage, connected, missing, completeness = _coverage(session, security_summary)
|
|
findings = _add_local_findings(session, _findings(session, review))
|
|
counts = _counts(findings)
|
|
score = max(0, min(100, 100 - sum(SEVERITY_PENALTY.get(finding['severity'], 0) for finding in findings if finding['status'] == 'open')))
|
|
recommendations = _base_recommendations(session, findings, coverage)
|
|
sections = _sections(findings, recommendations, coverage)
|
|
status = _status(score, counts, missing, findings)
|
|
summary = {
|
|
'status': status,
|
|
'score': score,
|
|
'generatedAt': _now(),
|
|
'dataCompleteness': completeness,
|
|
'coverageStatus': coverage,
|
|
'criticalCount': counts['critical'],
|
|
'highCount': counts['high'],
|
|
'mediumCount': counts['medium'],
|
|
'warningCount': counts['low'] + counts['medium'] + counts['high'],
|
|
'recommendationCount': len([item for item in recommendations if item['status'] == 'open']),
|
|
'connectedSources': connected,
|
|
'missingSources': missing,
|
|
'sections': sections,
|
|
}
|
|
score_breakdown = {
|
|
'baseScore': 100,
|
|
'finalScore': score,
|
|
'penalties': [
|
|
{
|
|
'source': finding['source'],
|
|
'reason': finding['title'],
|
|
'severity': finding['severity'],
|
|
'points': SEVERITY_PENALTY.get(finding['severity'], 0),
|
|
'findingId': finding['id'],
|
|
}
|
|
for finding in findings
|
|
if finding['status'] == 'open' and SEVERITY_PENALTY.get(finding['severity'], 0) > 0
|
|
],
|
|
'bonuses': [],
|
|
'missingCoverageImpact': 'Missing coverage lowers confidence and data completeness; it is not treated as critical without evidence.',
|
|
'criticalEvidence': [finding['id'] for finding in findings if finding['status'] == 'open' and finding['severity'] == 'critical'],
|
|
'explanation': f'Startscore 100 minus open finding penalties. Current status is {status}.',
|
|
'missingSources': missing,
|
|
'dataCompleteness': completeness,
|
|
'coverageStatus': coverage,
|
|
}
|
|
return {'summary': _redact(summary), 'sections': _redact(sections), 'findings': _redact(findings), 'recommendations': _redact(recommendations), 'scoreBreakdown': _redact(score_breakdown)}
|
|
|
|
|
|
def _coverage_sources(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|
coverage = payload.get('summary', {}).get('coverageStatus', {})
|
|
if not isinstance(coverage, dict):
|
|
return []
|
|
return [
|
|
{
|
|
'id': source,
|
|
'label': detail.get('label') or SOURCE_LABELS.get(source, source),
|
|
'status': detail.get('status', 'unknown'),
|
|
'effectOnConfidence': detail.get('effectOnConfidence'),
|
|
}
|
|
for source, detail in sorted(coverage.items())
|
|
if isinstance(detail, dict)
|
|
]
|
|
|
|
|
|
def _with_cache_metadata(payload: dict[str, Any], *, hit: bool, source: str, generated_at: str, expires_at: str, ttl_seconds: int, generation_duration_ms: int, stale_reason: str | None = None) -> dict[str, Any]:
|
|
dashboard = dict(payload)
|
|
dashboard['generatedAt'] = generated_at
|
|
dashboard['coverageSources'] = _coverage_sources(payload)
|
|
cache_meta: dict[str, Any] = {
|
|
'hit': hit,
|
|
'ttlSeconds': ttl_seconds,
|
|
'expiresAt': expires_at,
|
|
'source': source,
|
|
'generationDurationMs': generation_duration_ms,
|
|
}
|
|
if stale_reason:
|
|
cache_meta['staleReason'] = _redact(stale_reason)
|
|
dashboard['cache'] = cache_meta
|
|
return _redact(dashboard)
|
|
|
|
|
|
def _persistent_cache(session: Session, signature: str) -> dict[str, Any] | None:
|
|
row = session.get(AppSetting, CACHE_SETTING_KEY)
|
|
if not row or not isinstance(row.value_json, dict):
|
|
return None
|
|
value = row.value_json
|
|
if value.get('schemaVersion') != CACHE_SCHEMA_VERSION:
|
|
return None
|
|
if value.get('signature') != signature:
|
|
return None
|
|
payload = value.get('payload')
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
return {
|
|
'key': CACHE_KEY,
|
|
'signature': signature,
|
|
'payload': payload,
|
|
'generatedAt': value.get('generatedAt'),
|
|
'expiresAt': value.get('expiresAt'),
|
|
'generationDurationMs': value.get('generationDurationMs', 0),
|
|
}
|
|
|
|
|
|
def _store_persistent_cache(session: Session, cache: dict[str, Any]) -> None:
|
|
row = session.get(AppSetting, CACHE_SETTING_KEY)
|
|
if not row:
|
|
row = AppSetting(key=CACHE_SETTING_KEY, is_secret=False)
|
|
row.value_json = {
|
|
'schemaVersion': CACHE_SCHEMA_VERSION,
|
|
'signature': cache.get('signature'),
|
|
'payload': _redact(cache.get('payload')),
|
|
'generatedAt': cache.get('generatedAt'),
|
|
'expiresAt': cache.get('expiresAt'),
|
|
'generationDurationMs': cache.get('generationDurationMs', 0),
|
|
}
|
|
row.updated_at = utc_now()
|
|
session.add(row)
|
|
session.commit()
|
|
|
|
|
|
def _schedule_background_refresh() -> bool:
|
|
global _background_refresh_running
|
|
with _background_refresh_lock:
|
|
if _background_refresh_running:
|
|
return False
|
|
_background_refresh_running = True
|
|
|
|
def refresh_worker() -> None:
|
|
global _background_refresh_running
|
|
try:
|
|
from ..db.session import engine
|
|
|
|
with Session(engine) as refresh_session:
|
|
get_dashboard(refresh_session, force_refresh=True)
|
|
except Exception: # noqa: BLE001 - stale safety data remains available on refresh failure.
|
|
logger.exception('Background Safety dashboard refresh failed')
|
|
finally:
|
|
with _background_refresh_lock:
|
|
_background_refresh_running = False
|
|
|
|
Thread(target=refresh_worker, name='appops-safety-refresh', daemon=True).start()
|
|
return True
|
|
|
|
|
|
def get_dashboard(session: Session, force_refresh: bool = False) -> dict[str, Any]:
|
|
global _dashboard_cache, _skip_persistent_cache_once
|
|
ttl_seconds = _ttl_seconds()
|
|
now_dt = datetime.now(UTC)
|
|
signature = _source_signature(session)
|
|
if _dashboard_cache and not force_refresh and _dashboard_cache.get('signature') == signature:
|
|
expires_at = _parse_utc(str(_dashboard_cache['expiresAt']))
|
|
if now_dt < expires_at:
|
|
return _with_cache_metadata(
|
|
_dashboard_cache['payload'],
|
|
hit=True,
|
|
source='cache',
|
|
generated_at=str(_dashboard_cache['generatedAt']),
|
|
expires_at=str(_dashboard_cache['expiresAt']),
|
|
ttl_seconds=ttl_seconds,
|
|
generation_duration_ms=int(_dashboard_cache.get('generationDurationMs', 0)),
|
|
)
|
|
|
|
skip_persistent = _skip_persistent_cache_once
|
|
_skip_persistent_cache_once = False
|
|
persisted = None if force_refresh or skip_persistent else _persistent_cache(session, signature)
|
|
if persisted:
|
|
expires_at = _parse_utc(str(persisted['expiresAt']))
|
|
max_stale_seconds = _persistent_cache_max_stale_seconds()
|
|
stale_until = expires_at.timestamp() + max_stale_seconds
|
|
if now_dt < expires_at:
|
|
_dashboard_cache = persisted
|
|
return _with_cache_metadata(
|
|
persisted['payload'],
|
|
hit=True,
|
|
source='cache',
|
|
generated_at=str(persisted['generatedAt']),
|
|
expires_at=str(persisted['expiresAt']),
|
|
ttl_seconds=ttl_seconds,
|
|
generation_duration_ms=int(persisted.get('generationDurationMs', 0)),
|
|
)
|
|
if max_stale_seconds > 0 and now_dt.timestamp() < stale_until:
|
|
_dashboard_cache = persisted
|
|
_schedule_background_refresh()
|
|
return _with_cache_metadata(
|
|
persisted['payload'],
|
|
hit=True,
|
|
source='stale-while-revalidate',
|
|
generated_at=str(persisted['generatedAt']),
|
|
expires_at=str(persisted['expiresAt']),
|
|
ttl_seconds=ttl_seconds,
|
|
generation_duration_ms=int(persisted.get('generationDurationMs', 0)),
|
|
)
|
|
|
|
started = perf_counter()
|
|
try:
|
|
payload = _payload(session)
|
|
except Exception as exc:
|
|
# A stale dashboard must still match the current configuration. In
|
|
# particular it cannot bring back suppressed ACL warnings after a
|
|
# diagnostics preference change.
|
|
if _dashboard_cache and _dashboard_cache.get('signature') == signature:
|
|
return _with_cache_metadata(
|
|
_dashboard_cache['payload'],
|
|
hit=True,
|
|
source='stale',
|
|
generated_at=str(_dashboard_cache['generatedAt']),
|
|
expires_at=str(_dashboard_cache['expiresAt']),
|
|
ttl_seconds=ttl_seconds,
|
|
generation_duration_ms=int(_dashboard_cache.get('generationDurationMs', 0)),
|
|
stale_reason=str(exc),
|
|
)
|
|
raise
|
|
|
|
duration_ms = int((perf_counter() - started) * 1000)
|
|
generated_at = str(payload['summary']['generatedAt'])
|
|
expires_at = (now_dt.replace(microsecond=0).timestamp() + ttl_seconds)
|
|
expires_at_iso = datetime.fromtimestamp(expires_at, UTC).isoformat().replace('+00:00', 'Z')
|
|
_dashboard_cache = {
|
|
'key': CACHE_KEY,
|
|
'signature': signature,
|
|
'payload': payload,
|
|
'generatedAt': generated_at,
|
|
'expiresAt': expires_at_iso,
|
|
'generationDurationMs': duration_ms,
|
|
}
|
|
_store_persistent_cache(session, _dashboard_cache)
|
|
logger.info('Safety dashboard generated in %sms with score=%s status=%s', duration_ms, payload['summary'].get('score'), payload['summary'].get('status'))
|
|
return _with_cache_metadata(
|
|
payload,
|
|
hit=False,
|
|
source='fresh',
|
|
generated_at=generated_at,
|
|
expires_at=expires_at_iso,
|
|
ttl_seconds=ttl_seconds,
|
|
generation_duration_ms=duration_ms,
|
|
)
|
|
|
|
|
|
def get_summary(session: Session) -> dict[str, Any]:
|
|
return get_dashboard(session)['summary']
|
|
|
|
|
|
def get_sections(session: Session) -> list[dict[str, Any]]:
|
|
return get_dashboard(session)['sections']
|
|
|
|
|
|
def get_findings(session: Session) -> list[dict[str, Any]]:
|
|
return get_dashboard(session)['findings']
|
|
|
|
|
|
def get_recommendations(session: Session) -> list[dict[str, Any]]:
|
|
return get_dashboard(session)['recommendations']
|
|
|
|
|
|
def get_score_breakdown(session: Session) -> dict[str, Any]:
|
|
return get_dashboard(session)['scoreBreakdown']
|
|
|
|
|
|
def refresh(session: Session) -> dict[str, Any]:
|
|
invalidate_cache()
|
|
dashboard = get_dashboard(session, force_refresh=True)
|
|
log_event(
|
|
session,
|
|
source='manual',
|
|
identity='safety_center',
|
|
action_id='safety.refresh',
|
|
target='safety-center',
|
|
target_type='safety',
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason='Safety Center read-only refresh.',
|
|
result='success',
|
|
redacted_metadata={'score': dashboard['summary']['score'], 'status': dashboard['summary']['status'], 'findings': len(dashboard['findings']), 'cache': dashboard['cache']['source']},
|
|
)
|
|
return dashboard
|
|
|
|
|
|
def acknowledge_finding(session: Session, finding_id: str) -> dict[str, Any]:
|
|
return _set_state(session, 'finding', finding_id, 'acknowledged')
|
|
|
|
|
|
def dismiss_finding(session: Session, finding_id: str) -> dict[str, Any]:
|
|
return _set_state(session, 'finding', finding_id, 'dismissed')
|
|
|
|
|
|
def dismiss_recommendation(session: Session, recommendation_id: str) -> dict[str, Any]:
|
|
return _set_state(session, 'recommendation', recommendation_id, 'dismissed')
|
|
|
|
|
|
def create_remediation_plan(session: Session, recommendation_id: str) -> dict[str, Any]:
|
|
recommendation = next((item for item in get_recommendations(session) if item['id'] == recommendation_id), None)
|
|
if not recommendation:
|
|
return {'success': False, 'status': 'not_found', 'recommendationId': recommendation_id, 'executable': False, 'href': '/ai/remediation-planner'}
|
|
finding_id = recommendation.get('sourceFindingId')
|
|
plan_id = None
|
|
if finding_id:
|
|
plan = ai_remediation_planner.generate_from_finding(session, str(finding_id))
|
|
plan_id = plan.id
|
|
log_event(
|
|
session,
|
|
source='manual',
|
|
identity='safety_center',
|
|
action_id='safety.recommendation.create_remediation_plan',
|
|
target=recommendation_id,
|
|
target_type='safety_recommendation',
|
|
risk='low',
|
|
policy_decision='allowed',
|
|
reason='Safety Center linked a non-executing remediation plan.',
|
|
result='success',
|
|
redacted_metadata={'recommendationId': recommendation_id, 'planId': plan_id},
|
|
)
|
|
invalidate_cache()
|
|
href = f'/ai/remediation-planner?plan={plan_id}' if plan_id else '/ai/remediation-planner'
|
|
return {'success': True, 'status': 'plan_linked', 'recommendationId': recommendation_id, 'planId': plan_id, 'href': href, 'executable': False}
|