1018 lines
57 KiB
Python
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
|