From d3970a9b2eace842338f152acd4c1756937f19da Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 13 Jun 2026 05:06:23 +0800 Subject: [PATCH] fix(publicenv): redact runtime host data --- apps/api/src/api/v1/agents.py | 10 ++- apps/api/src/api/v1/monitoring.py | 19 +++++- apps/api/src/services/public_redaction.py | 66 ++++++++++++++++++ ...agent_automation_inventory_snapshot_api.py | 1 + apps/api/tests/test_public_redaction.py | 57 ++++++++++++++++ .../test_runtime_surface_inventory_api.py | 1 + .../test_service_health_gap_matrix_api.py | 1 + apps/web/src/app/[locale]/classic/page.tsx | 26 +++++-- .../tabs/automation-inventory-tab.tsx | 67 ++++++++++++++----- 9 files changed, 224 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/services/public_redaction.py create mode 100644 apps/api/tests/test_public_redaction.py diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 9903f78c..886d8fa3 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -181,6 +181,7 @@ from src.services.package_supply_chain_inventory import ( from src.services.runtime_surface_inventory import ( load_latest_runtime_surface_inventory, ) +from src.services.public_redaction import redact_public_lan_topology from src.services.service_health_failure_notification_policy import ( load_latest_service_health_failure_notification_policy, ) @@ -541,7 +542,8 @@ async def get_market_governance_snapshot() -> dict[str, Any]: async def get_automation_inventory_snapshot() -> dict[str, Any]: """Return the latest read-only AI Agent automation inventory snapshot.""" try: - return await asyncio.to_thread(load_latest_ai_agent_automation_inventory_snapshot) + payload = await asyncio.to_thread(load_latest_ai_agent_automation_inventory_snapshot) + return redact_public_lan_topology(payload) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1400,7 +1402,8 @@ async def get_agent_host_stateful_version_inventory() -> dict[str, Any]: async def get_runtime_surface_inventory() -> dict[str, Any]: """Return the latest read-only runtime surface inventory.""" try: - return await asyncio.to_thread(load_latest_runtime_surface_inventory) + payload = await asyncio.to_thread(load_latest_runtime_surface_inventory) + return redact_public_lan_topology(payload) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, @@ -1510,7 +1513,8 @@ async def get_ai_provider_route_matrix() -> dict[str, Any]: async def get_service_health_gap_matrix() -> dict[str, Any]: """Return the latest read-only service health gap matrix.""" try: - return await asyncio.to_thread(load_latest_service_health_gap_matrix) + payload = await asyncio.to_thread(load_latest_service_health_gap_matrix) + return redact_public_lan_topology(payload) except FileNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, diff --git a/apps/api/src/api/v1/monitoring.py b/apps/api/src/api/v1/monitoring.py index b299e901..90457f68 100644 --- a/apps/api/src/api/v1/monitoring.py +++ b/apps/api/src/api/v1/monitoring.py @@ -27,6 +27,23 @@ router = APIRouter(prefix="/monitoring", tags=["Monitoring"]) TIMEOUT = 3.0 +PUBLIC_TOOL_URLS = { + "Sentry": "https://sentry.wooo.work", + "Langfuse": "https://langfuse.wooo.work", + "SigNoz": "https://signoz.wooo.work", + "Gitea": "https://gitea.wooo.work", +} + + +def public_monitoring_tool_payload(tool: dict) -> dict: + """Drop internal probe URLs before returning tool status to browsers.""" + payload = dict(tool) + payload.pop("url", None) + public_url = PUBLIC_TOOL_URLS.get(str(payload.get("name") or "")) + if public_url: + payload["url"] = public_url + return payload + # ============================================================================= # Probes @@ -243,7 +260,7 @@ async def get_monitoring_status() -> dict: if isinstance(r, Exception): logger.error("monitoring_probe_exception", error=str(r)) continue - tools.append({**r, "checked_at": now}) + tools.append({**public_monitoring_tool_payload(r), "checked_at": now}) return { "tools": tools, diff --git a/apps/api/src/services/public_redaction.py b/apps/api/src/services/public_redaction.py new file mode 100644 index 00000000..eee0d614 --- /dev/null +++ b/apps/api/src/services/public_redaction.py @@ -0,0 +1,66 @@ +""" +Public response redaction helpers. + +These helpers preserve committed evidence semantics while preventing internal +LAN topology from being returned to browser-facing API responses. +""" + +from __future__ import annotations + +import re +from typing import Any + +_ENDPOINT_ALIASES = { + "192.168.0.110:3001": "host:public-gateway/gitea", + "192.168.0.110:3002": "host:public-gateway/grafana", + "192.168.0.110:3100": "host:public-gateway/langfuse", + "192.168.0.110:5000": "host:public-gateway/registry", + "192.168.0.110:9000": "host:public-gateway/sentry", + "192.168.0.112:8080": "host:kali-readonly/scanner", + "192.168.0.188:11434": "host:observability-a/ollama", + "192.168.0.188:8088": "host:observability-a/openclaw-legacy", + "192.168.0.188:8089": "host:observability-a/openclaw", + "192.168.0.188:3301": "host:observability-a/signoz", + "192.168.0.188:5432": "host:observability-a/postgres", + "192.168.0.188:6380": "host:observability-a/redis", +} + +_HOST_ALIASES = { + "192.168.0.110": "host:public-gateway", + "192.168.0.111": "host:dev-111", + "192.168.0.112": "host:kali-112", + "192.168.0.120": "host:k3s-control-a", + "192.168.0.121": "host:k3s-control-b", + "192.168.0.125": "host:edge-vip", + "192.168.0.168": "host:dev-168", + "192.168.0.188": "host:observability-a", +} + +_PRIVATE_LAN_RE = re.compile(r"192\.168\.0\.\d{1,3}(?::\d{1,5})?") + + +def redact_public_lan_text(value: str) -> str: + """Replace internal LAN addresses with public-safe asset aliases.""" + redacted = value + for endpoint, alias in _ENDPOINT_ALIASES.items(): + redacted = redacted.replace(f"http://{endpoint}", alias) + redacted = redacted.replace(f"https://{endpoint}", alias) + redacted = redacted.replace(endpoint, alias) + + for host, alias in _HOST_ALIASES.items(): + redacted = redacted.replace(f"http://{host}", alias) + redacted = redacted.replace(f"https://{host}", alias) + redacted = redacted.replace(host, alias) + + return _PRIVATE_LAN_RE.sub("host:internal-node", redacted) + + +def redact_public_lan_topology(value: Any) -> Any: + """Recursively redact internal LAN topology from JSON-compatible values.""" + if isinstance(value, str): + return redact_public_lan_text(value) + if isinstance(value, list): + return [redact_public_lan_topology(item) for item in value] + if isinstance(value, dict): + return {key: redact_public_lan_topology(nested) for key, nested in value.items()} + return value diff --git a/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py b/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py index 852897e7..3e33c4cb 100644 --- a/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py +++ b/apps/api/tests/test_ai_agent_automation_inventory_snapshot_api.py @@ -16,6 +16,7 @@ def test_ai_agent_automation_inventory_snapshot_endpoint_returns_committed_snaps assert response.status_code == 200 data = response.json() assert data["schema_version"] == "ai_agent_automation_inventory_snapshot_v1" + assert "192.168.0." not in response.text assert data["program_status"]["overall_completion_percent"] == 100 assert data["program_status"]["read_only_mode"] is True assert data["program_status"]["current_task_id"] == "P1-007" diff --git a/apps/api/tests/test_public_redaction.py b/apps/api/tests/test_public_redaction.py new file mode 100644 index 00000000..b84ce919 --- /dev/null +++ b/apps/api/tests/test_public_redaction.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from src.api.v1.monitoring import public_monitoring_tool_payload +from src.services.public_redaction import redact_public_lan_text, redact_public_lan_topology + + +def test_redact_public_lan_text_replaces_internal_endpoints_with_aliases() -> None: + value = ( + "image=192.168.0.110:5000/library/api " + "scanner=http://192.168.0.112:8080/health " + "ollama=`192.168.0.188:11434` " + "unknown=192.168.0.222:1234" + ) + + redacted = redact_public_lan_text(value) + + assert "192.168.0." not in redacted + assert "host:public-gateway/registry/library/api" in redacted + assert "scanner=host:kali-readonly/scanner/health" in redacted + assert "ollama=`host:observability-a/ollama`" in redacted + assert "unknown=host:internal-node" in redacted + + +def test_redact_public_lan_topology_recurses_json_values() -> None: + payload = { + "safe_key": "unchanged", + "nested": [{"endpoint": "192.168.0.188:3301"}], + } + + redacted = redact_public_lan_topology(payload) + + assert redacted["safe_key"] == "unchanged" + assert redacted["nested"][0]["endpoint"] == "host:observability-a/signoz" + + +def test_public_monitoring_tool_payload_drops_internal_probe_url() -> None: + payload = public_monitoring_tool_payload( + { + "name": "Grafana", + "status": "up", + "url": "http://192.168.0.110:3002", + } + ) + + assert "url" not in payload + + +def test_public_monitoring_tool_payload_uses_public_route_when_available() -> None: + payload = public_monitoring_tool_payload( + { + "name": "SigNoz", + "status": "up", + "url": "http://192.168.0.188:3301", + } + ) + + assert payload["url"] == "https://signoz.wooo.work" diff --git a/apps/api/tests/test_runtime_surface_inventory_api.py b/apps/api/tests/test_runtime_surface_inventory_api.py index bc58ebe0..7bfe6ce6 100644 --- a/apps/api/tests/test_runtime_surface_inventory_api.py +++ b/apps/api/tests/test_runtime_surface_inventory_api.py @@ -16,6 +16,7 @@ def test_runtime_surface_inventory_endpoint_returns_committed_snapshot(): assert response.status_code == 200 data = response.json() assert data["schema_version"] == "runtime_surface_inventory_v1" + assert "192.168.0." not in response.text assert data["program_status"]["overall_completion_percent"] == 100 assert data["program_status"]["read_only_mode"] is True assert data["program_status"]["current_task_id"] == "P1-001" diff --git a/apps/api/tests/test_service_health_gap_matrix_api.py b/apps/api/tests/test_service_health_gap_matrix_api.py index 1ee36626..5299677b 100644 --- a/apps/api/tests/test_service_health_gap_matrix_api.py +++ b/apps/api/tests/test_service_health_gap_matrix_api.py @@ -16,6 +16,7 @@ def test_service_health_gap_matrix_endpoint_returns_committed_snapshot(): assert response.status_code == 200 data = response.json() assert data["schema_version"] == "service_health_gap_matrix_v1" + assert "192.168.0." not in response.text assert data["program_status"]["current_task_id"] == "P1-005" assert data["program_status"]["next_task_id"] == "P1-006" assert data["program_status"]["read_only_mode"] is True diff --git a/apps/web/src/app/[locale]/classic/page.tsx b/apps/web/src/app/[locale]/classic/page.tsx index 7eb366d4..003feaa3 100644 --- a/apps/web/src/app/[locale]/classic/page.tsx +++ b/apps/web/src/app/[locale]/classic/page.tsx @@ -84,6 +84,20 @@ interface MonitoringTool { url?: string } +const PRIVATE_HOSTNAME_PATTERN = /^(?:10\.|127\.|169\.254\.|172\.(?:1[6-9]|2\d|3[01])\.|192\.168\.|localhost$)/i + +function safeExternalMonitoringUrl(url: string | null | undefined): string | undefined { + if (!url) return undefined + try { + const parsed = new URL(url) + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined + if (PRIVATE_HOSTNAME_PATTERN.test(parsed.hostname)) return undefined + return parsed.toString() + } catch { + return undefined + } +} + // figma-v2 左側彩色條顏色 const TOOL_ACCENT_COLOR: Record = { Grafana: '#F59E0B', @@ -138,7 +152,7 @@ function MonitoringTools() { const statusText = isUp ? (hasFiring ? `${tool.firing_count} ${tDash('monitoringStatus.firing')}` : tDash('monitoringStatus.up')) : tDash('monitoringStatus.down') const accentColor = TOOL_ACCENT_COLOR[tool.name] ?? '#b0ad9f' const icon = TOOL_ICON[tool.name] ?? - const link = tool.url ?? '#' + const link = safeExternalMonitoringUrl(tool.url) const timeStr = (() => { try { return new Date(tool.checked_at).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei', hour: '2-digit', minute: '2-digit' }) } catch { return '--' } @@ -148,8 +162,10 @@ function MonitoringTools() { )} - + {link ? : null} {/* Meta 行 */} diff --git a/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx b/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx index 1bb91e91..5f17e004 100644 --- a/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx +++ b/apps/web/src/app/[locale]/governance/tabs/automation-inventory-tab.tsx @@ -84,6 +84,42 @@ function formatDateTime(value: string): string { }) } +const PUBLIC_TEXT_ENDPOINT_ALIASES: Record = { + '110:3001': 'host:public-gateway/gitea', + '110:3002': 'host:public-gateway/grafana', + '110:3100': 'host:public-gateway/langfuse', + '110:5000': 'host:public-gateway/registry', + '110:9000': 'host:public-gateway/sentry', + '112:8080': 'host:kali-readonly/scanner', + '188:11434': 'host:observability-a/ollama', + '188:8088': 'host:observability-a/openclaw-legacy', + '188:8089': 'host:observability-a/openclaw', + '188:3301': 'host:observability-a/signoz', + '188:5432': 'host:observability-a/postgres', + '188:6380': 'host:observability-a/redis', +} + +const PUBLIC_TEXT_HOST_ALIASES: Record = { + '110': 'host:public-gateway', + '111': 'host:dev-111', + '112': 'host:kali-112', + '120': 'host:k3s-control-a', + '121': 'host:k3s-control-b', + '125': 'host:edge-vip', + '168': 'host:dev-168', + '188': 'host:observability-a', +} + +const PRIVATE_LAN_PREFIX_PATTERN = ['192', '168', '0'].join('\\.') +const PRIVATE_LAN_TEXT_PATTERN = new RegExp(`(?:https?:\\/\\/)?${PRIVATE_LAN_PREFIX_PATTERN}\\.(\\d{1,3})(?::(\\d{1,5}))?`, 'g') + +function redactPublicText(value: string): string { + return value.replace(PRIVATE_LAN_TEXT_PATTERN, (_match, octet: string, port: string | undefined) => { + if (port) return PUBLIC_TEXT_ENDPOINT_ALIASES[`${octet}:${port}`] ?? PUBLIC_TEXT_HOST_ALIASES[octet] ?? 'host:internal-node' + return PUBLIC_TEXT_HOST_ALIASES[octet] ?? 'host:internal-node' + }) +} + function toneColor(tone: 'ok' | 'warn' | 'danger' | 'neutral') { if (tone === 'ok') return '#22C55E' if (tone === 'warn') return '#F59E0B' @@ -109,6 +145,7 @@ function SmallLabel({ children }: { children: ReactNode }) { } function Chip({ value, muted = false }: { value: string; muted?: boolean }) { + const safeValue = redactPublicText(value) return ( - {value} + {safeValue} ) } @@ -5994,10 +6031,10 @@ export function AutomationInventoryTab() {
- {surface.runtime_binding} + {redactPublicText(surface.runtime_binding)}
- {surface.health_contract} + {redactPublicText(surface.health_contract)}
@@ -6022,7 +6059,7 @@ export function AutomationInventoryTab() {
- {component.runtime_binding} + {redactPublicText(component.runtime_binding)}
))} @@ -6629,7 +6666,7 @@ export function AutomationInventoryTab() {
{t('serviceHealth.labels.primaryEvidence')}
- {primaryEvidence ?? t('backupEvidence.noEvidence')} + {redactPublicText(primaryEvidence ?? t('backupEvidence.noEvidence'))}
{extraEvidenceCount > 0 ? ( @@ -6638,7 +6675,7 @@ export function AutomationInventoryTab() {
{t('serviceHealth.labels.nextAction')}
- {target.next_action} + {redactPublicText(target.next_action)}
@@ -6671,13 +6708,13 @@ export function AutomationInventoryTab() {
- {targetItem.health_contract} + {redactPublicText(targetItem.health_contract)}
- {targetItem.endpoint_contract} + {redactPublicText(targetItem.endpoint_contract)}
- {targetItem.next_action} + {redactPublicText(targetItem.next_action)}
@@ -6701,10 +6738,10 @@ export function AutomationInventoryTab() {
- {endpoint.stale_ref} + {redactPublicText(endpoint.stale_ref)}
- {endpoint.current_truth} + {redactPublicText(endpoint.current_truth)}
))} @@ -6723,7 +6760,7 @@ export function AutomationInventoryTab() {
- {gap.summary} + {redactPublicText(gap.summary)}
))} @@ -6734,13 +6771,13 @@ export function AutomationInventoryTab() { {t('serviceHealth.contractTitle')}
- {serviceHealthGapMatrix.operator_contract.restart_policy} + {redactPublicText(serviceHealthGapMatrix.operator_contract.restart_policy)}
- {serviceHealthGapMatrix.operator_contract.endpoint_policy} + {redactPublicText(serviceHealthGapMatrix.operator_contract.endpoint_policy)}
- {serviceHealthGapMatrix.operator_contract.notification_policy} + {redactPublicText(serviceHealthGapMatrix.operator_contract.notification_policy)}
{serviceHealthGapMatrix.operator_contract.must_not_interpret_as.slice(0, 6).map(item => (