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

552 lines
43 KiB
Python

from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlmodel import Session, select
from ..core.redaction import redact_message
from ..core.time import utc_now
from ..db.models import AppSetting, Connector
from ..schemas.ai_remediation import (
RemediationApprovalProposal,
RemediationApprovalRequirement,
RemediationDryRunResult,
RemediationDryRunStepResult,
RemediationEvidence,
RemediationPlan,
RemediationPlanCollection,
RemediationStep,
RemediationVerificationCheck,
)
from . import ai_security_reviewer
from .audit_logger import log_event
from .policy_engine import evaluate_policy
from .secret_store import list_secret_status, secret_storage_status
SOURCE_KEY = 'ai_remediation'
MUTATION_TOKENS = ('delete', 'remove', 'prune', 'format', 'renew', 'create', 'update', 'rotate', 'redeploy', 'restart', 'resume', 'pause', 'retry')
def _now() -> str:
return datetime.now(UTC).isoformat()
def _redact(value: Any) -> Any:
if isinstance(value, str):
lower = value.lower()
if any(token in lower for token in ('token_value', 'secret_value', 'api_key=', 'password=', 'bearer ')):
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 _setting_key(kind: str, plan_id: str) -> str:
return f'{SOURCE_KEY}:{kind}:{plan_id}'
def _get_status(session: Session, plan_id: str) -> str:
row = session.get(AppSetting, _setting_key('plan', plan_id))
if not row:
return 'draft'
return str(row.value_json.get('status') or 'draft')
def _set_status(session: Session, plan_id: str, status: str) -> dict[str, Any]:
row = session.get(AppSetting, _setting_key('plan', plan_id))
if not row:
row = AppSetting(key=_setting_key('plan', plan_id), is_secret=False)
row.value_json = {'status': status, 'updatedAt': _now()}
row.updated_at = utc_now()
session.add(row)
session.commit()
_audit(session, f'ai.remediation.plan.{status}', plan_id, {'status': status})
plan = get_plan(session, plan_id)
return {'success': True, 'id': plan_id, 'status': status, 'plan': plan.model_dump() if plan else None}
def _audit(session: Session, action_id: str, target: str, metadata: dict[str, Any] | None = None) -> None:
log_event(
session,
source='manual',
identity='ai_remediation_planner',
action_id=action_id,
target=target,
target_type='ai_remediation',
risk='low',
policy_decision='allowed',
reason='AI Remediation Planner V1 non-executing workflow.',
result='success',
redacted_metadata=_redact(metadata or {}),
)
def _evidence(source: str, label: str, value: Any = None) -> RemediationEvidence:
redacted = _redact(value)
return RemediationEvidence(source=source, label=label, value=redacted, redacted=redacted != value)
def _check(label: str, endpoint: str, expected: str, source: str) -> RemediationVerificationCheck:
return RemediationVerificationCheck(label=label, endpoint=endpoint, expectedResult=expected, source=source, readOnly=True)
def _resource_sample(resources: list[str], limit: int = 5) -> str:
sample = resources[:limit]
if not sample:
return 'unknown resource'
suffix = f' (+{len(resources) - limit} more)' if len(resources) > limit else ''
return ', '.join(sample) + suffix
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 f'/setup?step=connectors&connector={connector.id}'
return '/secrets'
def _approval(session: Session, action_id: str, target: str, source: str = 'manual') -> RemediationApprovalRequirement:
policy = evaluate_policy(session, action=action_id, target=target, source=source)
return RemediationApprovalRequirement(
actionId=action_id,
target=target,
reason=str(policy.get('reason') or 'Policy evaluation required.'),
risk=str(policy.get('risk') or 'medium'),
policyDecision=str(policy.get('decision') or 'blocked'),
)
def _step(
session: Session,
step_id: str,
label: str,
description: str,
step_type: str,
*,
href: str | None = None,
action_id: str | None = None,
target: str | None = None,
source: str = 'manual',
evidence: list[RemediationEvidence] | None = None,
) -> RemediationStep:
policy = {'decision': 'allowed', 'risk': 'low'}
if action_id and target:
policy = evaluate_policy(session, action=action_id, target=target, source=source)
return RemediationStep(
id=step_id,
label=label,
description=description,
type=step_type, # type: ignore[arg-type]
actionId=action_id,
target=target,
href=href,
policyDecision=str(policy.get('decision') or 'allowed'),
risk=str(policy.get('risk') or 'low'),
requiresApproval=policy.get('decision') == 'approval_required',
canExecute=False,
evidence=evidence or [],
)
def _blocked(session: Session, step_id: str, label: str, action_id: str, target: str, description: str) -> RemediationStep:
return _step(session, step_id, label, description, 'blocked_action', action_id=action_id, target=target)
def _base_plan(
session: Session,
*,
plan_id: str,
title: str,
description: str,
source_type: str,
source_id: str,
severity: str,
confidence: str,
affected: list[str],
prerequisites: list[str],
steps: list[RemediationStep],
verification: list[RemediationVerificationCheck],
blocked: list[RemediationStep],
approvals: list[RemediationApprovalRequirement],
risk: str,
impact: str,
rollback: str,
) -> RemediationPlan:
now = _now()
return RemediationPlan(
id=plan_id,
title=str(_redact(title)),
description=str(_redact(description)),
sourceType=source_type, # type: ignore[arg-type]
sourceId=source_id,
severity=severity, # type: ignore[arg-type]
confidence=confidence, # type: ignore[arg-type]
status=_get_status(session, plan_id), # type: ignore[arg-type]
affectedResources=[str(_redact(item)) for item in affected],
prerequisites=[str(_redact(item)) for item in prerequisites],
steps=steps,
verificationChecks=verification,
blockedActions=blocked,
approvalRequirements=approvals,
riskAssessment=str(_redact(risk)),
estimatedImpact=str(_redact(impact)),
rollbackNotes=str(_redact(rollback)),
auditPreview=f'Plan {plan_id} can be reviewed or dry-run only; no remediation execution is available in V1.',
createdAt=now,
updatedAt=now,
executable=False,
)
def _generic_finding(finding_id: str) -> dict[str, Any]:
title_map = {
'ai-sec-auth-public-host-without-authentik': ('Public host without Authentik mapping', 'high', 'auth_coverage', ['public_host']),
'ai-sec-npm-certificate-expiring-soon': ('NPM certificate expiring soon', 'medium', 'exposure', ['certificate']),
'ai-sec-npm-host-without-certificate': ('NPM public host without certificate', 'high', 'exposure', ['public_host']),
'ai-sec-npm-host-without-ssl-force': ('NPM host does not force SSL', 'medium', 'exposure', ['public_host']),
'ai-sec-auth-outpost-unavailable': ('Expected Authentik outpost unavailable', 'medium', 'auth_coverage', ['authentik']),
'ai-sec-policy-unraid-mover-voice-allowed': ('Unraid mover voice action is allowed', 'high', 'policies', ['unraid.mover.start']),
'ai-sec-media-pipeline-issue': ('Media pipeline needs review', 'medium', 'media', ['media_pipeline']),
}
title, severity, category, affected = title_map.get(finding_id, ('Security finding needs remediation plan', 'medium', 'unknown', ['unknown']))
return {
'id': finding_id,
'title': title,
'description': title,
'severity': severity,
'confidence': 'medium',
'category': category,
'affectedResources': affected,
'evidence': [],
}
def _plan_for_finding(session: Session, finding: dict[str, Any]) -> RemediationPlan:
finding_id = str(finding.get('id') or 'unknown')
title = str(_redact(finding.get('title') or finding_id))
severity = str(finding.get('severity') or 'medium')
confidence = str(finding.get('confidence') or 'medium')
affected = [str(item) for item in finding.get('affectedResources', [])] or ['unknown']
evidence = [_evidence(str(item.get('source', 'security')), str(item.get('label', 'evidence')), item.get('value', item.get('observed'))) for item in finding.get('evidence', []) if isinstance(item, dict)]
if 'missing-secret' in finding_id or finding.get('category') == 'secrets':
secret_ref = affected[0]
setup_href = _connector_setup_href_for_secret(session, secret_ref)
steps = [
_step(session, 'open-secrets', 'Open connector setup', 'Open the write-only connector configuration page.', 'open_page', href=setup_href, evidence=evidence),
_step(session, 'configure-secret', 'Configure required secret', 'Enter the connector secret in setup. The planner never reads or stores the value.', 'configure_secret', href=setup_href),
_step(session, 'test-connector', 'Test connector', 'Run the existing connector test after the secret is configured.', 'run_readonly_check', href=setup_href),
_step(session, 'rerun-review', 'Re-run security review', 'Confirm the finding is resolved after connector status updates.', 'run_readonly_check', href='/ai/security-reviewer'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Remediate missing secret: {secret_ref}', description='Configure the missing connector secret through the write-only vault and validate with read-only checks.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Operator has the secret value outside AppOps.', 'Secret storage key is configured.'], steps=steps, verification=[_check('Connector status connected', '/api/connectors/{id}/test', 'connected or configured', 'connectors'), _check('Security finding resolved', '/api/ai/security/findings?scope=secrets', 'finding absent or acknowledged', 'ai_security')], blocked=[], approvals=[], risk='Low operational risk; primary risk is entering the wrong secret.', impact='Connector read operations become available after configuration.', rollback='Replace the secret with the previous value in the vault if validation fails.')
if 'auth-public-host-without-authentik' in finding_id or 'authentik-missing-public-hosts' in finding_id:
host_count = len(affected)
host_label = 'public host' if host_count == 1 else 'public hosts'
host_sample = _resource_sample(affected)
host_evidence = evidence + [_evidence('authentik', 'affectedHosts', affected)]
blocked = [
_blocked(session, 'blocked-npm-update', 'Automatic NPM modification blocked', 'npm.update_proxy_host', 'nginx_proxy_manager', 'NPM host changes are manual in V1.'),
_blocked(session, 'blocked-auth-create', 'Automatic Authentik application/provider creation blocked', 'authentik.create_application', 'authentik', 'Identity-provider changes are manual in V1.'),
]
approvals = [_approval(session, 'authentik.create_application', 'authentik'), _approval(session, 'npm.update_proxy_host', 'nginx_proxy_manager')]
steps = [
_step(session, 'open-network', 'Open Exposure Center', f'Review the affected public host list host-by-host: {host_sample}.', 'open_page', href='/network', evidence=host_evidence),
_step(session, 'review-npm-host', 'Review NPM proxy host', 'Confirm host-by-host whether each route should stay public and which access list or external auth applies.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'),
_step(session, 'open-authentik', 'Open Authentik applications', 'Review existing Authentik applications/providers/outposts.', 'run_readonly_check', action_id='authentik.auth_coverage', target='authentik', href='/apps/authentik'),
_step(session, 'manual-auth-change', 'Manually create or verify Authentik coverage', 'Make required Authentik/NPM changes outside this planner for every affected host that should be identity-protected.', 'manual_external_change', href='/apps/authentik'),
_step(session, 'rerun-coverage', 'Re-run Auth Coverage', 'Confirm every affected host is protected or intentionally reviewed.', 'run_readonly_check', action_id='authentik.auth_coverage', target='authentik', href='/network'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Add identity coverage for {host_count} {host_label}', description=f'{title}: {host_sample}', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM and Authentik connectors are configured.', 'Operator can administer Authentik/NPM directly.', 'Each affected public host is reviewed host-by-host before external changes.'], steps=steps, verification=[_check('Auth coverage protected', '/api/apps/authentik/auth-coverage', 'each affected host is protected, likely_protected, or explicitly reviewed', 'authentik')], blocked=blocked, approvals=approvals, risk='High if left unresolved because public routes may bypass identity controls.', impact='Manual external changes may affect public access to one or more hosts.', rollback='Revert external Authentik/NPM changes in their native UIs if access breaks.')
if 'certificate-expiring' in finding_id or 'without-certificate' in finding_id:
blocked = [_blocked(session, 'blocked-cert-renew', 'Automatic certificate renew blocked', 'npm.renew_certificate', 'nginx_proxy_manager', 'Certificate renewal remains manual in NPM for V1.')]
steps = [
_step(session, 'open-npm', 'Open NPM AppDetail certificate panel', 'Inspect certificate metadata and affected domains.', 'open_page', href='/apps/nginx_proxy_manager', evidence=evidence),
_step(session, 'review-cert', 'Review certificate expiry', 'Validate expiry date, provider and covered domains.', 'run_readonly_check', action_id='npm.certificates', target='nginx_proxy_manager', href='/network'),
_step(session, 'manual-renew', 'Manually renew in NPM if needed', 'Perform renewal in NPM directly; this planner will not call renew.', 'manual_external_change', href='/apps/nginx_proxy_manager'),
_step(session, 'rerun-cert-check', 'Re-run NPM cert check', 'Refresh certificate warning data.', 'run_readonly_check', action_id='npm.expiring_certificates', target='nginx_proxy_manager', href='/network'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review expiring NPM certificate', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM connector can read certificate metadata.', 'Operator has NPM admin access.'], steps=steps, verification=[_check('Certificate no longer expiring', '/api/apps/nginx-proxy-manager/certificates/expiring?days=30', 'affected certificate absent', 'npm')], blocked=blocked, approvals=[_approval(session, 'npm.renew_certificate', 'nginx_proxy_manager')], risk='Medium; access can fail when TLS expires.', impact='Manual renewal may briefly affect the public host.', rollback='Restore previous certificate state in NPM if renewal introduces errors.')
if 'without-ssl-force' in finding_id or 'ssl force' in title.lower() or 'force ssl' in title.lower():
steps = [
_step(session, 'open-network', 'Open Exposure Center', 'Inspect the affected NPM public host and its SSL policy.', 'open_page', href='/network', evidence=evidence),
_step(session, 'review-proxy-host', 'Review proxy host SSL settings', 'Confirm Force SSL is disabled for the affected host and whether that is intentional.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'),
_step(session, 'manual-ssl-change', 'Manually enable Force SSL in NPM if appropriate', 'Change Force SSL in NPM directly after checking the app still works over HTTPS.', 'manual_external_change', href='/apps/nginx_proxy_manager'),
_step(session, 'rerun-exposure', 'Re-run exposure review', 'Confirm the SSL-force finding is resolved or explicitly acknowledged.', 'run_readonly_check', action_id='npm.proxy_hosts', target='nginx_proxy_manager', href='/network'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review NPM SSL enforcement', description=f'Force SSL is not enabled for: {_resource_sample(affected)}.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['NPM connector can read proxy-host metadata.', 'Operator has NPM admin access.', 'HTTPS certificate is valid for the affected host.'], steps=steps, verification=[_check('Proxy host forces SSL', '/api/apps/nginx-proxy-manager/proxy-hosts', 'affected host has sslForced=true or finding acknowledged', 'npm')], blocked=[_blocked(session, 'blocked-npm-update', 'Automatic NPM proxy-host update blocked', 'npm.update_proxy_host', 'nginx_proxy_manager', 'Planner V1 does not mutate NPM proxy hosts.')], approvals=[_approval(session, 'npm.update_proxy_host', 'nginx_proxy_manager')], risk='Medium; users may reach the host over plain HTTP if Force SSL is disabled.', impact='Manual Force SSL changes can affect clients using HTTP-only URLs.', rollback='Disable Force SSL again in NPM if the host breaks, then review certificate and upstream settings.')
if 'outpost-unavailable' in finding_id or 'outpost' in title.lower():
steps = [
_step(session, 'open-authentik', 'Open Authentik AppDetail', 'Inspect Authentik connector health and outpost metadata.', 'open_page', href='/apps/authentik', evidence=evidence),
_step(session, 'review-outpost-health', 'Review outpost health', 'Read the Authentik outpost health summary and provider count.', 'run_readonly_check', action_id='authentik.outpost_health', target='authentik', href='/apps/authentik'),
_step(session, 'manual-outpost-review', 'Verify outpost in Authentik', 'Use Authentik directly to check the embedded outpost, providers and integration health.', 'manual_external_change', href='/apps/authentik'),
_step(session, 'rerun-auth-review', 'Re-run Authentik security review', 'Confirm outpost health is healthy or intentionally documented.', 'run_readonly_check', action_id='authentik.outpost_health', target='authentik', href='/ai/security-reviewer'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review Authentik outpost health', description='Authentik reports the expected outpost as unavailable or unknown.', source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Authentik connector is configured.', 'Operator can inspect Authentik outposts directly.'], steps=steps, verification=[_check('Outpost health is available', '/api/apps/authentik/outposts/health', 'available=true or explicitly reviewed', 'authentik')], blocked=[_blocked(session, 'blocked-auth-update', 'Automatic Authentik outpost mutation blocked', 'authentik.update_policy', 'authentik', 'Planner V1 does not mutate Authentik topology.')], approvals=[_approval(session, 'authentik.update_policy', 'authentik')], risk='Medium; unknown outpost health can make external auth coverage harder to trust.', impact='Manual Authentik changes can affect public authentication flows.', rollback='Revert outpost/provider changes in Authentik if application access is affected.')
if finding.get('category') == 'media':
steps = [
_step(session, 'open-media', 'Open Media Pipeline', 'Review connector status, stalled downloads and pipeline issues.', 'open_page', href='/media-pipeline', evidence=evidence),
_step(session, 'run-diagnose', 'Run media pipeline diagnose', 'Use the existing read-only diagnose flow to collect current media pipeline state.', 'run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline', href='/media-pipeline'),
_step(session, 'review-connectors', 'Review missing media connectors', 'Configure only the connectors you intend to use; missing optional connectors should remain visible as partial coverage.', 'bind_connector', href='/setup?step=connectors'),
_step(session, 'rerun-review', 'Re-run safety/security review', 'Confirm media finding is resolved, reduced to known partial coverage or acknowledged.', 'run_readonly_check', action_id='media_pipeline.diagnose', target='media-pipeline', href='/safety'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review media pipeline issue', description=str(_redact(finding.get('description') or title)), source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Media Pipeline route is reachable.', 'Operator knows which media connectors should be active in this deployment.'], steps=steps, verification=[_check('Media pipeline summary reviewed', '/api/media-pipeline/summary', 'status healthy, warning accepted, or partial coverage documented', 'media_pipeline'), _check('Media pipeline issues reviewed', '/api/media-pipeline/issues', 'no unexpected critical issues', 'media_pipeline')], blocked=[_blocked(session, 'blocked-deluge-delete-data', 'No delete/remove with data', 'deluge.remove_torrent_data', 'deluge', 'Delete-with-data remains blocked.')], approvals=[_approval(session, 'media_pipeline.diagnose', 'media-pipeline')], risk='Medium; media connector gaps can hide stalled downloads or processing failures.', impact='Read-only review has no runtime impact; any connector setup is manual.', rollback='Remove or replace connector settings manually if a configured connector is wrong.')
if finding.get('category') == 'policies' or 'policy-' in finding_id or 'voice' in title.lower():
action_id = affected[0] if affected else 'unraid.mover.start'
steps = [
_step(session, 'open-policies', 'Open Policies page', 'Inspect the risky policy rule.', 'open_page', href='/policies', evidence=evidence),
_step(session, 'simulate-voice', 'Simulate voice policy decision', 'Use policy simulation for the affected action and target.', 'review_policy', action_id=action_id, target='unraid', source='voice', href='/policies'),
_step(session, 'draft-policy', 'Create policy draft suggestion', 'Draft a safer blocked or approval-required setting for operator review.', 'documentation', href='/policies'),
_step(session, 'manual-apply', 'Manual policy apply outside V1', 'Apply the policy change through an explicit future policy workflow, not this planner.', 'manual_external_change', href='/policies'),
]
blocked = [_blocked(session, 'blocked-policy-update', 'Automatic policy apply blocked', 'authentik.update_policy', 'policy', 'Planner V1 does not mutate policies.')]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Reduce risky voice policy', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Operator understands desired policy posture.', 'Policy changes are reviewed manually.'], steps=steps, verification=[_check('Policy no longer voice-allowed', '/api/policies/evaluate', 'blocked or approval_required for voice', 'policies')], blocked=blocked, approvals=[_approval(session, action_id, 'unraid', 'voice')], risk='High when voice can trigger state-changing operations.', impact='Safer policy can require more manual approvals.', rollback='Restore previous policy value from audit/config backup if needed.')
if finding.get('category') == 'inventory' or 'critical-app-unprotected' in finding_id or 'needs review' in title.lower():
app_id = affected[0]
steps = [
_step(session, 'open-inventory', 'Open Inventory/AppDetail', 'Review app import, category and criticality.', 'open_page', href='/inventory', evidence=evidence),
_step(session, 'mark-protected', 'Mark protected manually or review import', 'Use existing inventory workflows to classify the app.', 'manual_external_change', href=f'/apps/{app_id}'),
_step(session, 'review-policy-profile', 'Review policy profile', 'Confirm policy profile matches app criticality.', 'review_policy', href='/policies'),
_step(session, 'rerun-review', 'Re-run safety/security review', 'Confirm protection finding is gone.', 'run_readonly_check', href='/ai/security-reviewer'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Review critical app protection', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Inventory source is current.', 'Operator has enough context to classify the app.'], steps=steps, verification=[_check('App managed/protected', '/api/inventory/apps', 'protected=true or reviewed', 'inventory')], blocked=[_blocked(session, 'blocked-policy-mutation', 'Automatic policy mutation blocked', 'stack.redeploy', app_id, 'Planner does not mutate app policy profile.')], approvals=[], risk='High for critical apps without explicit protection.', impact='Manual classification affects future policy decisions.', rollback='Change classification back manually if misclassified.')
if finding.get('category') == 'audit' or 'blocked-attempts' in finding_id:
steps = [
_step(session, 'open-audit', 'Open Audit Log', 'Inspect repeated blocked/failed attempts.', 'open_page', href='/audit', evidence=evidence),
_step(session, 'review-automation', 'Review automation or voice source', 'Identify whether attempts are expected or noisy.', 'documentation', href='/audit'),
_step(session, 'review-policy', 'Review related policy', 'Confirm blocked behavior is intentional.', 'review_policy', href='/policies'),
]
return _base_plan(session, plan_id=f'rem-{finding_id}', title='Triage repeated blocked attempts', description=title, source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Recent audit events are available.'], steps=steps, verification=[_check('Blocked attempts decrease', '/api/audit', 'no repeated attempts in next window', 'audit')], blocked=[], approvals=[], risk='Medium; repeated denials can indicate noisy automation or probing.', impact='May require changing external automation or voice phrasing.', rollback='Re-enable previous automation only after policy review.')
return _base_plan(session, plan_id=f'rem-{finding_id}', title=f'Review finding: {title}', description=str(_redact(finding.get('description') or title)), source_type='security_finding', source_id=finding_id, severity=severity, confidence=confidence, affected=affected, prerequisites=['Review source finding evidence.'], steps=[_step(session, 'open-reviewer', 'Open Security Reviewer', 'Inspect finding evidence and links.', 'open_page', href='/ai/security-reviewer', evidence=evidence)], verification=[_check('Finding reviewed', '/api/ai/security/findings', 'status acknowledged or resolved', 'ai_security')], blocked=[], approvals=[], risk='Depends on source finding severity.', impact='No direct impact; V1 is plan-only.', rollback='Dismiss plan if not actionable.')
def _plan_for_media_issue(session: Session, issue: dict[str, Any]) -> RemediationPlan:
issue_id = str(issue.get('id') or issue.get('title') or 'media-issue').lower().replace(' ', '-')
title = str(_redact(issue.get('title') or 'Media pipeline issue'))
affected = [str(item) for item in issue.get('affectedConnectors', issue.get('affected', []))] or ['media_pipeline']
evidence = [_evidence('media_pipeline', 'issue', issue.get('evidence', []))]
if 'widefrog' in title.lower() or 'widefrog' in affected:
steps = [
_step(session, 'open-widefrog', 'Open Widefrog AppDetail', 'Review failed jobs and queue state.', 'open_page', href='/apps/widefrog', evidence=evidence),
_step(session, 'review-failed-jobs', 'Review failed jobs', 'Inspect failure reason before retry.', 'run_readonly_check', action_id='widefrog.failed_jobs', target='widefrog', href='/apps/widefrog'),
_step(session, 'retry-if-approved', 'Plan retry_failed through policy flow', 'Retry can only run through existing policy/approval flow outside this planner.', 'request_approval', action_id='widefrog.retry_failed', target='widefrog', href='/apps/widefrog'),
_step(session, 'verify-queue', 'Verify queue/activity', 'Confirm failed count decreases after manual execution.', 'run_readonly_check', action_id='widefrog.queue_status', target='widefrog', href='/apps/widefrog'),
]
blocked = [_blocked(session, 'blocked-delete-jobs', 'Delete jobs/media blocked', 'widefrog.delete_job', 'widefrog', 'Delete operations are forbidden.')]
return _base_plan(session, plan_id='rem-media-widefrog-failed', title='Plan Widefrog failed jobs recovery', description=title, source_type='media_issue', source_id=issue_id, severity='medium', confidence='medium', affected=affected, prerequisites=['Widefrog connector configured.', 'Operator confirms retry is safe.'], steps=steps, verification=[_check('Failed jobs decrease', '/api/apps/widefrog/failed', 'failed count lower', 'widefrog')], blocked=blocked, approvals=[_approval(session, 'widefrog.retry_failed', 'widefrog')], risk='Medium; retry can repeat failed processing.', impact='May increase load on media processing.', rollback='Stop external retry workflow if failures repeat.')
steps = [
_step(session, 'open-media', 'Open Media Pipeline', 'Review pipeline issue context.', 'open_page', href='/media-pipeline', evidence=evidence),
_step(session, 'open-deluge', 'Open Deluge detail', 'Review stalled torrents without deleting data.', 'run_readonly_check', action_id='deluge.stalled_downloads', target='deluge', href='/apps/deluge'),
_step(session, 'resume-specific', 'Plan resume specific torrent if policy allows', 'Resume must go through existing policy/approval flow outside this planner.', 'request_approval', action_id='deluge.resume_torrent', target='deluge', href='/apps/deluge'),
_step(session, 'verify-stalled', 'Verify stalled count decreases', 'Re-run download checks after manual action.', 'run_readonly_check', action_id='deluge.stalled_downloads', target='deluge', href='/media-pipeline'),
]
blocked = [_blocked(session, 'blocked-remove-data', 'No delete/remove with data', 'deluge.remove_torrent_data', 'deluge', 'Delete-with-data remains blocked.')]
return _base_plan(session, plan_id='rem-media-deluge-stalled', title='Plan Deluge stalled downloads recovery', description=title, source_type='media_issue', source_id=issue_id, severity='medium', confidence='medium', affected=affected, prerequisites=['Deluge connector configured.', 'Operator identifies the specific stalled torrent.'], steps=steps, verification=[_check('Stalled downloads decrease', '/api/apps/deluge/stalled', 'stalled count lower', 'deluge')], blocked=blocked, approvals=[_approval(session, 'deluge.resume_torrent', 'deluge')], risk='Medium; resuming downloads can affect bandwidth and storage.', impact='Download queue may resume network/storage activity.', rollback='Pause the specific torrent through normal policy flow if needed.')
def _inventory_plans(session: Session) -> list[RemediationPlan]:
from .app_registry import list_active_apps
plans: list[RemediationPlan] = []
for app in list_active_apps(session):
if app.needs_review:
finding = {'id': f'inventory-needs-review-{app.id}', 'title': f'Discovered app needs review: {app.name}', 'severity': app.criticality if app.criticality in {'low', 'medium', 'high', 'critical'} else 'medium', 'confidence': 'medium', 'category': 'inventory', 'affectedResources': [app.id], 'evidence': []}
plans.append(_plan_for_finding(session, finding))
break
return plans
def _connector_plans(session: Session) -> list[RemediationPlan]:
plans: list[RemediationPlan] = []
for connector in session.exec(select(Connector)).all():
if connector.status == 'missing_config':
finding = {'id': f'connector-missing-config-{connector.id}', 'title': f'Connector missing config: {connector.name}', 'severity': 'medium', 'confidence': 'high', 'category': 'connectors', 'affectedResources': [connector.id], 'evidence': [{'source': 'connectors', 'label': 'status', 'value': connector.status}]}
plans.append(_plan_for_finding(session, finding))
break
return plans
def _secret_storage_plan(session: Session) -> list[RemediationPlan]:
status = secret_storage_status()
if status.get('status') != 'warning' and status.get('productionReady') is not False:
return []
plan = _base_plan(
session,
plan_id='rem-secret-storage-key-warning',
title='Configure production secret storage key',
description='Configure APPOPS_SECRET_KEY or APPOPS_SECRET_KEY_FILE and back it up with the database.',
source_type='secret',
source_id='secret-storage-key-warning',
severity='medium',
confidence='high',
affected=['secret_storage'],
prerequisites=['Operator can set deployment environment variables.', 'Backup process is available.'],
steps=[
_step(session, 'open-settings', 'Open settings/secrets status', 'Review key source and production readiness.', 'open_page', href='/secrets'),
_step(session, 'read-docs', 'Open Secret Storage docs', 'Follow production key setup guidance.', 'documentation', href='/docs/SECRET_STORAGE.md'),
_step(session, 'configure-key', 'Configure APPOPS_SECRET_KEY', 'Set the key in deployment environment outside this planner.', 'manual_external_change'),
_step(session, 'backup-db-key', 'Back up database with key', 'Back up /data/appops.db and the key material together.', 'documentation'),
_step(session, 'runtime-health', 'Re-run runtime health', 'Confirm production readiness.', 'run_readonly_check', href='/settings'),
],
verification=[_check('Runtime health production ready', '/api/runtime/info', 'secretStorage.productionReady=true', 'runtime')],
blocked=[_blocked(session, 'blocked-key-rotation', 'Automated key rotation blocked', 'secrets.show', 'secret_storage', 'Key automation is not implemented in planner V1.')],
approvals=[],
risk='Medium; missing production key affects encrypted secret durability.',
impact='Deployment environment change and backup process update.',
rollback='Restore previous environment and database backup if startup fails.',
)
return [plan]
def generate_plans(session: Session, audit_run: bool = False, include_media_issues: bool | None = None) -> RemediationPlanCollection:
if include_media_issues is None:
include_media_issues = audit_run
review = ai_security_reviewer.generate_review(session)
findings = [finding.model_dump() for finding in review.findings]
recommendations = [recommendation.model_dump() for recommendation in review.recommendations]
finding_ids = {str(finding.get('id')) for finding in findings}
plans = [_plan_for_finding(session, finding) for finding in findings if finding.get('status') != 'dismissed']
plans.extend(
_plan_for_recommendation(session, recommendation)
for recommendation in recommendations
if recommendation.get('status') != 'dismissed' and str(recommendation.get('id') or '').removeprefix('rec-') not in finding_ids
)
if include_media_issues:
try:
from . import media_pipeline_service
issues = media_pipeline_service.issues(session).get('issues', [])
except Exception as exc: # noqa: BLE001
issues = [{'id': 'media-unavailable', 'title': redact_message(str(exc)), 'affectedConnectors': ['media_pipeline']}]
plans.extend(_plan_for_media_issue(session, issue) for issue in issues if isinstance(issue, dict))
plans.extend(_inventory_plans(session))
plans.extend(_connector_plans(session))
plans.extend(_secret_storage_plan(session))
deduped = list({plan.id: plan for plan in plans}.values())
if audit_run:
_audit(session, 'ai.remediation.generate', 'all', {'plans': len(deduped)})
return RemediationPlanCollection(status='ok', generatedAt=_now(), total=len(deduped), plans=deduped)
def _plan_for_recommendation(session: Session, recommendation: dict[str, Any]) -> RemediationPlan:
source_id = str(recommendation.get('id') or 'recommendation')
finding_id = source_id.removeprefix('rec-')
finding = {
'id': finding_id,
'title': recommendation.get('title') or source_id,
'description': recommendation.get('description') or recommendation.get('suggestedAction') or source_id,
'severity': recommendation.get('severity') or 'medium',
'confidence': recommendation.get('confidence') or 'medium',
'category': 'unknown',
'affectedResources': recommendation.get('affectedResources', []),
'evidence': recommendation.get('evidence', []),
}
plan = _plan_for_finding(session, finding)
return plan.model_copy(update={'id': f'rem-{source_id}', 'sourceType': 'security_recommendation', 'sourceId': source_id, 'status': _get_status(session, f'rem-{source_id}')})
def get_plans(session: Session) -> RemediationPlanCollection:
return generate_plans(session)
def get_plan(session: Session, plan_id: str) -> RemediationPlan | None:
plan = next((item for item in generate_plans(session).plans if item.id == plan_id), None)
if plan:
return plan
if plan_id.startswith('rem-ai-sec-'):
return _plan_for_finding(session, _generic_finding(plan_id.removeprefix('rem-')))
return None
def generate_from_finding(session: Session, finding_id: str) -> RemediationPlan:
finding = ai_security_reviewer.get_finding(session, finding_id) or _generic_finding(finding_id)
plan = _plan_for_finding(session, finding)
_audit(session, 'ai.remediation.plan.generated', plan.id, {'sourceType': 'security_finding', 'sourceId': finding_id})
return plan
def generate_from_recommendation(session: Session, recommendation_id: str) -> RemediationPlan:
recommendation = next((item for item in ai_security_reviewer.get_recommendations(session) if item['id'] == recommendation_id), {'id': recommendation_id, 'title': recommendation_id, 'severity': 'medium', 'confidence': 'medium', 'affectedResources': []})
plan = _plan_for_recommendation(session, recommendation)
_audit(session, 'ai.remediation.plan.generated', plan.id, {'sourceType': 'security_recommendation', 'sourceId': recommendation_id})
return plan
def review_plan(session: Session, plan_id: str) -> dict[str, Any]:
return _set_status(session, plan_id, 'reviewed')
def dismiss_plan(session: Session, plan_id: str) -> dict[str, Any]:
return _set_status(session, plan_id, 'dismissed')
def dry_run(session: Session, plan_id: str) -> RemediationDryRunResult | None:
plan = get_plan(session, plan_id)
if not plan:
return None
step_results: list[RemediationDryRunStepResult] = []
approvals: list[RemediationApprovalRequirement] = []
blocked: list[RemediationApprovalRequirement] = []
reasons: list[str] = ['Planner V1 dry-run only; no steps were executed.']
for step in plan.steps + plan.blockedActions:
decision = step.policyDecision
risk = step.risk
reason = 'No executable action attached.'
if step.actionId and step.target:
policy = evaluate_policy(session, action=step.actionId, target=step.target, source='manual')
decision = str(policy.get('decision') or 'blocked')
risk = str(policy.get('risk') or step.risk)
reason = str(policy.get('reason') or 'Policy evaluated.')
status = 'blocked' if decision == 'blocked' or step.type == 'blocked_action' else ('warning' if decision == 'approval_required' else 'pass')
result = RemediationDryRunStepResult(stepId=step.id, label=step.label, actionId=step.actionId, target=step.target, policyDecision=decision, risk=risk, status=status, reason=reason, canExecute=False)
step_results.append(result)
if step.actionId and step.target and decision == 'approval_required':
approvals.append(RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason=reason, risk=risk, policyDecision=decision))
if step.actionId and step.target and status == 'blocked':
blocked.append(RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason=reason, risk=risk, policyDecision=decision))
missing_secret_names = [item.get('secretRef') for item in list_secret_status(session) if not item.get('hasSecret')]
unresolved = [f'Missing secret: {name}' for name in missing_secret_names if name]
if blocked:
status = 'blocked'
elif approvals or unresolved:
status = 'warning'
else:
status = 'pass'
result = RemediationDryRunResult(planId=plan.id, status=status, executed=False, reasons=reasons, prerequisites=plan.prerequisites, unresolvedBlockers=unresolved, blockedActions=blocked, approvalRequirements=approvals, verificationChecks=plan.verificationChecks, stepResults=step_results)
_audit(session, 'ai.remediation.dry_run', plan_id, {'status': status, 'blocked': len(blocked), 'approvals': len(approvals)})
return result
def create_approval_proposal(session: Session, plan_id: str) -> RemediationApprovalProposal | None:
plan = get_plan(session, plan_id)
if not plan:
return None
proposals = plan.approvalRequirements or [
RemediationApprovalRequirement(actionId=step.actionId, target=step.target, reason='Manual approval may be required by policy.', risk=step.risk, policyDecision=step.policyDecision)
for step in plan.steps
if step.actionId and step.target and step.policyDecision == 'approval_required'
]
row = session.get(AppSetting, _setting_key('proposal', plan_id))
if not row:
row = AppSetting(key=_setting_key('proposal', plan_id), is_secret=False)
row.value_json = {'createdAt': _now(), 'proposals': [proposal.model_dump() for proposal in proposals], 'executed': False, 'approvalId': None}
row.updated_at = utc_now()
session.add(row)
session.commit()
_audit(session, 'ai.remediation.approval_proposal', plan_id, {'proposals': len(proposals), 'executed': False})
return RemediationApprovalProposal(planId=plan_id, created=True, executed=False, approvalId=None, proposals=proposals, message='Approval proposal created as planner draft only. No approval or action was executed.')