from __future__ import annotations import re from dataclasses import dataclass from datetime import UTC, datetime from typing import Any from urllib.parse import urlparse import httpx from ..core.config import settings from ..core.redaction import redact_message from .base import BaseConnector @dataclass class _NpmConfig: url: str identity: str secret: str verify_tls: bool timeout_seconds: float def _as_bool(value: Any, default: bool = False) -> bool: if isinstance(value, bool): return value if value is None: return default return str(value).strip().lower() in {'1', 'true', 'yes', 'on'} def _as_float(value: Any, default: float) -> float: try: return float(value) except (TypeError, ValueError): return default def _as_int(value: Any, default: int = 0) -> int: try: return int(float(value)) except (TypeError, ValueError): return default def _as_list(value: Any) -> list[Any]: if isinstance(value, list): return value if value in {None, ''}: return [] return [value] def _safe_url(value: Any = None) -> str: raw = str(value or settings.apis.get('NPM_URL') or '').strip().rstrip('/') if not raw: return '' parsed = urlparse(raw) if not parsed.username and not parsed.password: return raw host = parsed.hostname or '' port = f':{parsed.port}' if parsed.port else '' return f'{parsed.scheme}://{host}{port}{parsed.path}'.rstrip('/') def _parse_date(value: Any) -> datetime | None: if not value: return None text = str(value).strip() if not text: return None for candidate in (text, text.replace('Z', '+00:00')): try: parsed = datetime.fromisoformat(candidate) return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) except ValueError: continue for fmt in ('%Y-%m-%d', '%Y-%m-%d %H:%M:%S'): try: return datetime.strptime(text, fmt).replace(tzinfo=UTC) except ValueError: continue return None def _days_until(value: Any) -> int | None: parsed = _parse_date(value) if not parsed: return None return (parsed - datetime.now(UTC)).days def _response_items(payload: Any) -> list[dict[str, Any]]: if isinstance(payload, list): return [item for item in payload if isinstance(item, dict)] if isinstance(payload, dict): data = payload.get('data') if isinstance(data, list): return [item for item in data if isinstance(item, dict)] if isinstance(payload.get('items'), list): return [item for item in payload['items'] if isinstance(item, dict)] return [] class NginxProxyManagerConnector(BaseConnector): name = 'Nginx Proxy Manager' def __init__(self): super().__init__() self._runtime_token: str | None = None self._read_cache: dict[str, dict[str, Any]] = {} def _memo(self, key: str, factory: Any) -> dict[str, Any]: if key not in self._read_cache: self._read_cache[key] = factory() return self._read_cache[key] def is_configured(self) -> bool: return bool(settings.apis.get('NPM_URL') and settings.apis.get('NPM_IDENTITY') and settings.apis.get('NPM_SECRET')) def _config(self) -> _NpmConfig | None: if not self.is_configured(): return None return _NpmConfig( url=str(settings.apis['NPM_URL']).rstrip('/'), identity=str(settings.apis['NPM_IDENTITY']), secret=str(settings.apis['NPM_SECRET']), verify_tls=_as_bool(settings.apis.get('NPM_VERIFY_TLS'), False), timeout_seconds=_as_float(settings.apis.get('NPM_TIMEOUT_SECONDS'), 10.0), ) def _login(self) -> str: if self._runtime_token: return self._runtime_token config = self._config() if not config: raise RuntimeError('missing_config') try: self.clear_error() with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: response = client.post( f'{config.url}/api/tokens', json={'identity': config.identity, 'secret': config.secret}, headers={'Accept': 'application/json'}, ) except httpx.RequestError as exc: message = redact_message(str(exc)) self.set_error(message) raise RuntimeError(message) if response.status_code in {400, 401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}') self.set_error(message) raise RuntimeError(message) try: payload = response.json() except ValueError as exc: message = redact_message(str(exc)) self.set_error(message) raise RuntimeError(message) token = payload.get('token') if isinstance(payload, dict) else None if not token: self.set_error('auth_failed') raise RuntimeError('auth_failed') self._runtime_token = str(token) return self._runtime_token def _request(self, method: str, path: str, **kwargs: Any) -> Any: config = self._config() if not config: raise RuntimeError('missing_config') token = self._login() try: with httpx.Client(timeout=config.timeout_seconds, verify=config.verify_tls) as client: response = client.request( method, f'{config.url}/api{path}', headers={'Accept': 'application/json', 'Authorization': f'Bearer {token}'}, **kwargs, ) except httpx.RequestError as exc: message = redact_message(str(exc)) self.set_error(message) raise RuntimeError(message) if response.status_code in {401, 403}: self.set_error('auth_failed') raise RuntimeError('auth_failed') if response.status_code >= 400: message = redact_message(f'NPM API error: HTTP {response.status_code} {(response.text or "")[:256]}') self.set_error(message) raise RuntimeError(message) try: return response.json() except ValueError as exc: message = redact_message(str(exc)) self.set_error(message) raise RuntimeError(message) def test_connection(self) -> dict[str, Any]: if not self.is_configured(): return {'status': 'missing_config', 'reason': 'missing_config'} try: self._login() hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100})) except RuntimeError as exc: reason = redact_message(str(exc)) if reason.startswith('auth_failed'): return {'status': 'auth_failed', 'reason': reason} if reason == 'missing_config': return {'status': 'missing_config', 'reason': reason} return {'status': 'unreachable', 'reason': reason} return {'status': 'connected', 'proxyHostCount': len(hosts)} def health(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(proxyHostCount=0, certificateCount=0) result = self.test_connection() status = str(result.get('status', 'error')) certs = self.certificates() if status == 'connected' else {'items': []} return { 'success': status == 'connected', 'status': status, 'message': 'Nginx Proxy Manager connector ready.' if status == 'connected' else redact_message(str(result.get('reason', 'connection_failed'))), 'url': _safe_url(), 'proxyHostCount': _as_int(result.get('proxyHostCount')), 'certificateCount': len(certs.get('items', [])) if isinstance(certs, dict) else 0, 'data': {}, } def proxy_hosts(self) -> dict[str, Any]: return self._memo('proxy_hosts', self._proxy_hosts_uncached) def _proxy_hosts_uncached(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0) try: items = [self._map_proxy_host(item) for item in _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 100}))] except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) return {'success': True, 'status': 'ok', 'message': 'proxy_hosts_ok', 'total': len(items), 'items': items, 'data': {'items': items}} def certificates(self) -> dict[str, Any]: return self._memo('certificates', self._certificates_uncached) def _certificates_uncached(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0) try: items = [self._map_certificate(item) for item in _response_items(self._request('GET', '/nginx/certificates', params={'limit': 100}))] except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) return {'success': True, 'status': 'ok', 'message': 'certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items}} def expiring_certificates(self, days: int = 30) -> dict[str, Any]: certs = self.certificates() items = [ item for item in certs.get('items', []) if item.get('isExpired') or (item.get('expiresInDays') is not None and int(item.get('expiresInDays')) <= days) ] return {**certs, 'message': 'expiring_certificates_ok', 'total': len(items), 'items': items, 'data': {'items': items, 'days': days}} def access_lists(self) -> dict[str, Any]: return self._memo('access_lists', self._access_lists_uncached) def _access_lists_uncached(self) -> dict[str, Any]: if not self.is_configured(): return self._missing(items=[], total=0) try: items = [self._map_access_list(item) for item in _response_items(self._request('GET', '/nginx/access-lists', params={'limit': 100}))] except RuntimeError as exc: return self._error_payload(exc, items=[], total=0) return {'success': True, 'status': 'ok', 'message': 'access_lists_ok', 'total': len(items), 'items': items, 'data': {'items': items}} def validate_managed_access_list(self, access_list_id: int) -> dict[str, Any]: """Read-only verification for a trusted managed-host access list. Expanded authorization entries are inspected in memory, then discarded; the result contains only the trusted numeric identifier and status. """ if not self.is_configured(): return self._missing(reason='missing_config') if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 1 <= access_list_id <= 2_147_483_647: return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'} try: access_lists = _response_items(self._request( 'GET', '/nginx/access-lists', params={'limit': 1000, 'expand': 'items,clients'}, )) except RuntimeError as exc: return self._error_payload(exc) matches = [item for item in access_lists if _as_int(item.get('id')) == access_list_id] if ( len(matches) != 1 or matches[0].get('satisfy_any', matches[0].get('satisfyAny')) is not False or not isinstance(matches[0].get('items'), list) or not matches[0]['items'] ): return {'success': False, 'status': 'blocked', 'message': 'required_access_list_unavailable'} return { 'success': True, 'status': 'verified', 'message': 'required_access_list_verified', 'accessListId': access_list_id, } def exposure_summary(self) -> dict[str, Any]: hosts = self.proxy_hosts() certs = self.certificates() access_lists = self.access_lists() if not hosts.get('success'): return {**hosts, 'data': {'warnings': []}} host_items = hosts.get('items', []) cert_items = certs.get('items', []) if certs.get('success') else [] cert_by_id = {item.get('id'): item for item in cert_items} warnings = self._build_warnings(host_items, cert_by_id) summary = { 'totalProxyHosts': len(host_items), 'enabledProxyHosts': sum(1 for item in host_items if item.get('enabled')), 'publicHosts': sum(1 for item in host_items if item.get('enabled')), 'hostsWithoutCertificate': sum(1 for item in host_items if item.get('enabled') and not item.get('certificateId')), 'hostsWithExpiredCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpired')), 'hostsWithExpiringCertificate': sum(1 for item in host_items if (cert_by_id.get(item.get('certificateId')) or {}).get('isExpiringSoon')), 'hostsWithoutAccessList': sum(1 for item in host_items if item.get('enabled') and not item.get('accessListId')), 'hostsWithoutSslForce': sum(1 for item in host_items if item.get('enabled') and not item.get('sslForced')), 'hostsWithWebsocket': sum(1 for item in host_items if item.get('websocketSupport')), 'accessLists': access_lists.get('total', 0), 'warnings': warnings, } return {'success': True, 'status': 'ok', 'message': 'exposure_summary_ok', **summary, 'data': summary} def host_reachability(self) -> dict[str, Any]: hosts = self.proxy_hosts() if not hosts.get('success'): return {**hosts, 'data': {'checks': []}} checks = [ { 'hostId': item.get('id'), 'domains': item.get('domainNames', []), 'target': f"{item.get('forwardScheme')}://{item.get('forwardHost')}:{item.get('forwardPort')}", 'status': 'metadata_only', 'message': 'V1 does not perform active target scans by default.', } for item in hosts.get('items', []) ] return {'success': True, 'status': 'metadata_only', 'message': 'host_reachability_metadata_only', 'checks': checks, 'data': {'checks': checks}} def security_warnings(self) -> dict[str, Any]: hosts = self.proxy_hosts() certs = self.certificates() if not hosts.get('success'): return {**hosts, 'items': [], 'total': 0, 'data': {'items': []}} cert_by_id = {item.get('id'): item for item in certs.get('items', [])} if certs.get('success') else {} items = self._build_warnings(hosts.get('items', []), cert_by_id) severity_order = {'critical': 3, 'high': 2, 'warning': 1, 'info': 0} status = 'warning' if items else 'ok' return { 'success': True, 'status': status, 'message': 'security_warnings_detected' if items else 'security_warnings_ok', 'total': len(items), 'items': sorted(items, key=lambda item: severity_order.get(str(item.get('severity')), 0), reverse=True), 'data': {'items': items}, } def ensure_managed_proxy_host( self, *, hostname: str, forward_host: str, forward_port: int, certificate_mode: str, force_ssl: bool, http2: bool, upload_limit_mb: int, read_timeout_seconds: int, ownership_id: str | None = None, access_list_id: int = 0, ) -> dict[str, Any]: """Create or verify one deployment-owned proxy host. This is intentionally not a general NPM mutation method. Every value is derived from DeploymentPlanV1 and this refuses to take over a host that does not already point at the exact managed container. """ if not self.is_configured(): return self._missing(reason='missing_config') if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]{0,251}[a-z0-9])?', hostname): return {'success': False, 'status': 'invalid', 'message': 'invalid_hostname'} if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,62}', forward_host): return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_host'} if not 1 <= int(forward_port) <= 65535: return {'success': False, 'status': 'invalid', 'message': 'invalid_forward_port'} if certificate_mode not in {'existing', 'managed'}: return {'success': False, 'status': 'invalid', 'message': 'invalid_certificate_mode'} if not force_ssl: return {'success': False, 'status': 'invalid', 'message': 'managed_proxy_requires_force_ssl'} if ownership_id is not None and not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id): return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'} if isinstance(access_list_id, bool) or not isinstance(access_list_id, int) or not 0 <= access_list_id <= 2_147_483_647: return {'success': False, 'status': 'invalid', 'message': 'invalid_access_list_id'} try: if access_list_id: access_list = self.validate_managed_access_list(access_list_id) if not access_list.get('success'): return access_list hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000})) exact = [ item for item in hosts if hostname in {str(value).strip().lower() for value in _as_list(item.get('domain_names') or item.get('domainNames'))} ] if len(exact) > 1: return {'success': False, 'status': 'conflict', 'message': 'multiple_hostname_owners'} if exact: existing = exact[0] same_target = ( str(existing.get('forward_host') or existing.get('forwardHost') or '') == forward_host and _as_int(existing.get('forward_port') or existing.get('forwardPort')) == int(forward_port) ) if not same_target: return {'success': False, 'status': 'conflict', 'message': 'hostname_owned_by_different_target'} metadata = existing.get('meta') if isinstance(existing.get('meta'), dict) else {} if ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id: return {'success': False, 'status': 'conflict', 'message': 'hostname_is_not_owned_by_this_enrollment'} hardened = ( bool(existing.get('ssl_forced') or existing.get('sslForced')) and bool(existing.get('http2_support') or existing.get('http2Support')) and bool(existing.get('block_exploits') or existing.get('blockExploits')) and not bool(existing.get('caching_enabled') or existing.get('cachingEnabled')) and not bool(existing.get('allow_websocket_upgrade') or existing.get('websocketSupport')) and bool(existing.get('certificate_id') or existing.get('certificateId')) and _as_int(existing.get('access_list_id') or existing.get('accessListId')) == access_list_id ) if not hardened: return {'success': False, 'status': 'conflict', 'message': 'existing_proxy_host_does_not_meet_managed_security_contract'} return { 'success': True, 'status': 'already_managed', 'message': 'proxy_host_already_matches_typed_plan', 'hostId': existing.get('id'), 'hostname': hostname, } certificate_id: Any = 0 metadata: dict[str, Any] = {} if certificate_mode == 'existing': certificates = _response_items(self._request('GET', '/nginx/certificates', params={'limit': 1000})) matching_certificate = next( ( item for item in certificates if hostname in { str(value).strip().lower() for value in _as_list(item.get('domain_names') or item.get('domainNames')) } ), None, ) if not matching_certificate: return {'success': False, 'status': 'blocked', 'message': 'existing_certificate_not_found'} certificate_id = matching_certificate.get('id') else: email = str(settings.apis.get('NPM_LETSENCRYPT_EMAIL') or '').strip() if not email or '@' not in email or len(email) > 254: return {'success': False, 'status': 'blocked', 'message': 'certificate_automation_not_configured'} certificate_id = 'new' metadata = {'letsencrypt_email': email, 'letsencrypt_agree': True} if ownership_id is not None: metadata = {**metadata, 'appops_enrollment_id': ownership_id} advanced = ( f'client_max_body_size {int(upload_limit_mb)}m;\n' f'proxy_read_timeout {int(read_timeout_seconds)}s;\n' f'proxy_send_timeout {int(read_timeout_seconds)}s;' ) created = self._request( 'POST', '/nginx/proxy-hosts', json={ 'domain_names': [hostname], 'forward_scheme': 'http', 'forward_host': forward_host, 'forward_port': int(forward_port), 'access_list_id': access_list_id, 'certificate_id': certificate_id, 'ssl_forced': bool(force_ssl), 'http2_support': bool(http2), 'hsts_enabled': False, 'hsts_subdomains': False, 'caching_enabled': False, 'block_exploits': True, 'allow_websocket_upgrade': False, 'meta': metadata, 'advanced_config': advanced, }, ) except RuntimeError as exc: return self._error_payload(exc) host_id = created.get('id') if isinstance(created, dict) else None if ownership_id is not None or access_list_id: if host_id is None: return { 'success': False, 'status': 'ownership_unverified', 'message': 'managed_proxy_creation_response_missing_identifier', } try: after_create = _response_items( self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000}) ) except RuntimeError as exc: return { 'success': False, 'status': 'ownership_unverified', 'message': redact_message(str(exc)), 'hostId': host_id, } verified = [item for item in after_create if str(item.get('id')) == str(host_id)] metadata = verified[0].get('meta') if len(verified) == 1 and isinstance(verified[0].get('meta'), dict) else {} same_target = bool( len(verified) == 1 and str(verified[0].get('forward_host') or verified[0].get('forwardHost') or '') == forward_host and _as_int(verified[0].get('forward_port') or verified[0].get('forwardPort')) == int(forward_port) and _as_int(verified[0].get('access_list_id') or verified[0].get('accessListId')) == access_list_id ) if not same_target or (ownership_id is not None and metadata.get('appops_enrollment_id') != ownership_id): return { 'success': False, 'status': 'ownership_unverified', 'message': 'managed_proxy_creation_ownership_not_verified', 'hostId': host_id, } result = { 'success': True, 'status': 'created', 'message': 'proxy_host_created_from_typed_plan', 'hostId': host_id, 'hostname': hostname, } if ownership_id is not None: result['enrollmentId'] = ownership_id return result def remove_managed_proxy_host(self, *, host_id: int | str, ownership_id: str) -> dict[str, Any]: """Compensate only a proxy host created by this exact enrollment.""" if not re.fullmatch(r'enr-[0-9a-f]{20}', ownership_id): return {'success': False, 'status': 'invalid', 'message': 'invalid_managed_ownership_id'} if not self.is_configured(): return self._missing(reason='missing_config') try: hosts = _response_items(self._request('GET', '/nginx/proxy-hosts', params={'limit': 1000})) matching = [item for item in hosts if str(item.get('id')) == str(host_id)] if len(matching) != 1: return {'success': False, 'status': 'not_found', 'message': 'managed_proxy_host_not_found'} metadata = matching[0].get('meta') if isinstance(matching[0].get('meta'), dict) else {} if metadata.get('appops_enrollment_id') != ownership_id: return {'success': False, 'status': 'conflict', 'message': 'proxy_host_is_not_owned_by_this_enrollment'} self._request('DELETE', f'/nginx/proxy-hosts/{host_id}') except RuntimeError as exc: return self._error_payload(exc) return {'success': True, 'status': 'removed', 'hostId': host_id, 'enrollmentId': ownership_id} @staticmethod def _map_proxy_host(raw: dict[str, Any]) -> dict[str, Any]: return { 'id': raw.get('id'), 'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))], 'forwardHost': str(raw.get('forward_host') or raw.get('forwardHost') or ''), 'forwardPort': _as_int(raw.get('forward_port') or raw.get('forwardPort')), 'forwardScheme': str(raw.get('forward_scheme') or raw.get('forwardScheme') or 'http'), 'enabled': bool(raw.get('enabled')), 'sslForced': bool(raw.get('ssl_forced') or raw.get('sslForced')), 'http2Support': bool(raw.get('http2_support') or raw.get('http2Support')), 'hstsEnabled': bool(raw.get('hsts_enabled') or raw.get('hstsEnabled')), 'certificateId': raw.get('certificate_id') or raw.get('certificateId') or None, 'accessListId': raw.get('access_list_id') or raw.get('accessListId') or None, 'blockExploits': bool(raw.get('block_exploits') or raw.get('blockExploits')), 'cachingEnabled': bool(raw.get('caching_enabled') or raw.get('cachingEnabled')), 'websocketSupport': bool(raw.get('allow_websocket_upgrade') or raw.get('websocket_support') or raw.get('websocketSupport')), 'locationsCount': len(_as_list(raw.get('locations'))), 'meta': raw.get('meta') if isinstance(raw.get('meta'), dict) else {}, } @staticmethod def _map_certificate(raw: dict[str, Any]) -> dict[str, Any]: expires = raw.get('expires_on') or raw.get('expiresOn') or raw.get('expires') days = _days_until(expires) return { 'id': raw.get('id'), 'niceName': str(raw.get('nice_name') or raw.get('niceName') or raw.get('name') or ''), 'domainNames': [str(item) for item in _as_list(raw.get('domain_names') or raw.get('domainNames'))], 'provider': str(raw.get('provider') or raw.get('type') or 'unknown').lower(), 'expiresOn': expires, 'expiresInDays': days, 'isExpired': days is not None and days < 0, 'isExpiringSoon': days is not None and days <= 30, } @staticmethod def _map_access_list(raw: dict[str, Any]) -> dict[str, Any]: return { 'id': raw.get('id'), 'name': str(raw.get('name') or ''), 'authorizationType': str(raw.get('auth_type') or raw.get('authorizationType') or raw.get('authorization_type') or 'unknown'), 'satisfyAny': bool(raw.get('satisfy_any') or raw.get('satisfyAny')), 'itemsCount': len(_as_list(raw.get('items'))), 'clientsCount': len(_as_list(raw.get('clients'))), } @staticmethod def _build_warnings(hosts: list[dict[str, Any]], cert_by_id: dict[Any, dict[str, Any]]) -> list[dict[str, Any]]: warnings: list[dict[str, Any]] = [] for host in hosts: if not host.get('enabled'): continue domains = host.get('domainNames') or [] label = ', '.join(domains) or f"host-{host.get('id')}" cert = cert_by_id.get(host.get('certificateId')) if host.get('certificateId') else None target = f"{host.get('forwardScheme')}://{host.get('forwardHost')}:{host.get('forwardPort')}" if not host.get('certificateId'): warnings.append({'type': 'host_without_certificate', 'severity': 'high', 'title': f'{label} has no certificate', 'evidence': [label, target], 'suggestedAction': 'review_certificate'}) elif cert and cert.get('isExpired'): warnings.append({'type': 'certificate_expired', 'severity': 'critical', 'title': f'{label} certificate is expired', 'evidence': [label, str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'}) elif cert and cert.get('isExpiringSoon'): warnings.append({'type': 'certificate_expiring_soon', 'severity': 'warning', 'title': f'{label} certificate expires soon', 'evidence': [label, f"{cert.get('expiresInDays')} days", str(cert.get('expiresOn'))], 'suggestedAction': 'review_certificate_in_npm'}) if not host.get('accessListId'): warnings.append({'type': 'public_host_without_access_list', 'severity': 'high', 'title': f'{label} has no access list', 'evidence': [label, target], 'suggestedAction': 'review_access_protection'}) if not host.get('sslForced'): warnings.append({'type': 'ssl_not_forced', 'severity': 'warning', 'title': f'{label} does not force SSL', 'evidence': [label], 'suggestedAction': 'review_ssl_policy'}) if not host.get('hstsEnabled'): warnings.append({'type': 'hsts_disabled', 'severity': 'info', 'title': f'{label} has HSTS disabled', 'evidence': [label], 'suggestedAction': 'review_hsts_policy'}) if str(host.get('forwardHost') or '').lower() in {'unknown-app', 'unknown', ''}: warnings.append({'type': 'unknown_proxy_target', 'severity': 'warning', 'title': f'{label} forwards to an unknown target', 'evidence': [target], 'suggestedAction': 'map_proxy_host_target'}) return warnings @staticmethod def _missing(**extra: Any) -> dict[str, Any]: return {'success': False, 'status': 'missing_config', 'message': 'missing_config', 'url': _safe_url(), 'required_env_vars': ['NPM_URL', 'NPM_IDENTITY', 'NPM_SECRET'], 'data': {}, **extra} @staticmethod def _error_payload(exc: Exception, **extra: Any) -> dict[str, Any]: reason = redact_message(str(exc)) status = 'auth_failed' if reason.startswith('auth_failed') else ('missing_config' if reason == 'missing_config' else 'unreachable') return {'success': False, 'status': status, 'message': reason, 'data': {}, **extra}