diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 091c134e7..4fcc95b54 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -20,6 +20,9 @@ from src.services.iwooos_runtime_security_readback import ( from src.services.iwooos_high_value_config_control_coverage import ( load_latest_iwooos_high_value_config_control_coverage, ) +from src.services.iwooos_owner_evidence_intake_preflight import ( + load_latest_iwooos_owner_evidence_intake_preflight, +) from src.services.iwooos_security_control_coverage import ( load_latest_iwooos_security_control_coverage, ) @@ -199,3 +202,31 @@ async def get_iwooos_high_value_config_control_coverage() -> dict[str, Any]: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"IwoooS high-value config control coverage 無效:{exc}", ) from exc + + +@router.get( + "/api/v1/iwooos/owner-evidence-intake-preflight", + response_model=dict[str, Any], + summary="取得 IwoooS 負責人脫敏證據收件預檢", + description=( + "整合 high-value config owner packet、配置覆蓋矩陣與 Wazuh 負責人證據預檢," + "回傳 Nginx、DNS / TLS、K8s、secret / runner、public runtime config 與 Wazuh registry " + "的公開安全收件欄位、拒收規則與 0 / false 邊界。此端點不送 owner request、不收回覆、" + "不寫 reviewer queue、不讀 secret、不查 live host、不查 Wazuh API、不啟動 runtime action。" + ), +) +async def get_iwooos_owner_evidence_intake_preflight() -> dict[str, Any]: + """回傳 IwoooS 負責人脫敏證據收件預檢公開安全只讀狀態。""" + try: + payload = await asyncio.to_thread(load_latest_iwooos_owner_evidence_intake_preflight) + return redact_public_lan_topology(payload) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"IwoooS owner evidence intake preflight 無效:{exc}", + ) from exc diff --git a/apps/api/src/services/iwooos_owner_evidence_intake_preflight.py b/apps/api/src/services/iwooos_owner_evidence_intake_preflight.py new file mode 100644 index 000000000..078ff3f36 --- /dev/null +++ b/apps/api/src/services/iwooos_owner_evidence_intake_preflight.py @@ -0,0 +1,454 @@ +""" +IwoooS 負責人脫敏證據收件預檢讀回。 + +此服務只整合已提交 snapshot 的公開安全欄位,不查 live host、不讀 +Nginx、不查 Wazuh API、不收 secret、不送 owner request,也不建立 +reviewer queue 或 runtime action。 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from src.services.snapshot_paths import default_security_dir + +_DEFAULT_SECURITY_DIR = default_security_dir(Path(__file__)) +_SCHEMA_VERSION = "iwooos_owner_evidence_intake_preflight_v1" +_INTAKE_SCHEMA = "high_value_config_owner_packet_intake_preflight_v1" +_COVERAGE_SCHEMA = "high_value_config_control_coverage_v1" +_WAZUH_SCHEMA = "wazuh_agent_visibility_owner_evidence_preflight_v1" + +_SNAPSHOTS = { + "intake": "high-value-config-owner-packet-intake-preflight.snapshot.json", + "coverage": "high-value-config-control-coverage.snapshot.json", + "wazuh": "wazuh-agent-visibility-owner-evidence-preflight.snapshot.json", +} + +_REQUIRED_FALSE_BOUNDARIES = { + "action_buttons_allowed", + "active_scan_authorized", + "dispatch_authorized", + "dns_tls_change_authorized", + "host_write_authorized", + "nginx_reload_authorized", + "production_write_authorized", + "request_sent", + "reviewer_queue_write", + "runtime_execution_authorized", + "secret_value_collection_allowed", + "workflow_modification_authorized", +} + +_OWNER_FIELDS = [ + "owner_role_or_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan", +] + +_LANE_ORDER = [ + "nginx_public_gateway", + "dns_tls_certbot", + "k8s_production_gitops", + "secret_workflow_runner_source_control", + "public_admin_api_runtime_config", + "wazuh_manager_registry", +] + +_PUBLIC_LABELS = { + "nginx_public_gateway": "Nginx / 公開入口 / 反向代理", + "dns_tls_certbot": "DNS / TLS / 憑證續期", + "k8s_production_gitops": "K8s / ArgoCD / 正式環境宣告", + "secret_workflow_runner_source_control": "機密中繼資料 / 工作流程 / 執行器", + "public_admin_api_runtime_config": "公開 / 後台 / API runtime config", + "wazuh_manager_registry": "Wazuh manager registry / 逐主機矩陣", +} + +_REQUIRED_EVIDENCE = { + "nginx_public_gateway": [ + "owner-provided live conf 脫敏參照", + "source-to-live rendered diff 參照", + "nginx -t 結果參照", + "affected route smoke 參照", + "maintenance window 與 rollback owner", + ], + "dns_tls_certbot": [ + "domain / certificate path 對照參照", + "ACME challenge route smoke 參照", + "renewal window 與 rollback owner", + "public HTTPS smoke 參照", + ], + "k8s_production_gitops": [ + "ArgoCD app health / sync / revision 參照", + "manifest diff 與 rollback revision", + "Pending / image pull / scheduling 摘要", + "route / AI / monitoring impact 參照", + ], + "secret_workflow_runner_source_control": [ + "secret name parity state 參照", + "workflow diff state 參照", + "runner owner attestation", + "deploy marker / run readback", + "log redaction readback", + ], + "public_admin_api_runtime_config": [ + "affected route refs", + "admin / auth boundary", + "API contract readback", + "frontend env / i18n redaction review", + "desktop / mobile production smoke", + ], + "wazuh_manager_registry": [ + "manager registry 脫敏計數", + "逐主機 alias matrix", + "Dashboard API 狀態參照", + "manager API version / health 參照", + "缺席主機 owner decision", + ], +} + +_BLOCKED_RUNTIME_ACTIONS = { + "nginx_public_gateway": ["nginx_reload", "host_write", "dns_tls_modify", "secret_value_submit"], + "dns_tls_certbot": ["certbot_renew", "dns_tls_modify", "nginx_reload", "secret_value_submit"], + "k8s_production_gitops": ["argocd_sync", "kubectl_apply", "secret_value_submit", "production_write"], + "secret_workflow_runner_source_control": [ + "workflow_modify", + "runner_enable", + "secret_value_submit", + "webhook_secret_submit", + ], + "public_admin_api_runtime_config": ["production_write", "cors_change", "route_change", "secret_value_submit"], + "wazuh_manager_registry": [ + "wazuh_api_live_query", + "wazuh_active_response", + "host_write", + "active_scan", + "raw_payload_store", + ], +} + + +def load_latest_iwooos_owner_evidence_intake_preflight( + security_dir: Path | None = None, +) -> dict[str, Any]: + """載入 IwoooS 統一負責人脫敏證據收件預檢。""" + sec_dir = security_dir or _DEFAULT_SECURITY_DIR + intake = _load_json(sec_dir / _SNAPSHOTS["intake"], _INTAKE_SCHEMA) + coverage = _load_json(sec_dir / _SNAPSHOTS["coverage"], _COVERAGE_SCHEMA) + wazuh = _load_json(sec_dir / _SNAPSHOTS["wazuh"], _WAZUH_SCHEMA) + _validate_intake(intake) + _validate_coverage(coverage) + _validate_wazuh(wazuh) + + packet_by_category = { + packet.get("category_id"): packet + for packet in intake.get("intake_packets") or [] + if isinstance(packet, dict) and packet.get("category_id") + } + coverage_by_category = { + category.get("category_id"): category + for category in coverage.get("coverage_categories") or [] + if isinstance(category, dict) and category.get("category_id") + } + lanes = [ + _build_lane(lane_id, packet_by_category, coverage_by_category, wazuh) + for lane_id in _LANE_ORDER + ] + summary = _summary(intake, wazuh, lanes) + + return { + "schema_version": _SCHEMA_VERSION, + "source_schema_versions": { + "high_value_config_owner_packet_intake_preflight": _INTAKE_SCHEMA, + "high_value_config_control_coverage": _COVERAGE_SCHEMA, + "wazuh_owner_evidence_preflight": _WAZUH_SCHEMA, + }, + "status": "owner_evidence_intake_preflight_ready_no_runtime_action", + "mode": "committed_snapshot_projection_only_no_live_runtime_query", + "summary": summary, + "lanes": lanes, + "dispatch_preflight_checks": _public_checks(intake.get("dispatch_preflight_checks")), + "reviewer_intake_lanes": _public_reviewer_lanes(intake.get("reviewer_intake_lanes")), + "blocked_requests": _blocked_requests(intake, wazuh), + "execution_boundaries": _execution_boundaries(), + "boundary_markers": _boundary_markers(summary), + "no_false_green_rules": [ + "收件預檢 ready 不代表 request 已送出、owner response 已收到、reviewer 已接受或 runtime gate 已開。", + "只收脫敏 evidence ref、ticket、commit、hash 或 metadata pointer;secret value、raw payload、token、private key、cookie、session 一律拒收或隔離。", + "Nginx reload、DNS / TLS 變更、ArgoCD sync、kubectl apply、workflow 修改、runner enable、Wazuh active response、Kali active scan 都不是此預檢授權。", + "前台卡片、API 200、一般工作批准或 Dashboard 可開,不能替代 owner-provided redacted evidence 與 reviewer validation。", + ], + "source_refs": [ + f"docs/security/{_SNAPSHOTS['intake']}", + f"docs/security/{_SNAPSHOTS['coverage']}", + f"docs/security/{_SNAPSHOTS['wazuh']}", + "scripts/security/high-value-config-owner-packet-intake-preflight.py", + "scripts/security/wazuh-agent-visibility-owner-evidence-preflight.py", + ], + } + + +def _load_json(path: Path, schema: str) -> dict[str, Any]: + if not path.is_file(): + raise FileNotFoundError(f"{path}: snapshot 不存在") + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError(f"{path}: expected JSON object") + if payload.get("schema_version") != schema: + raise ValueError(f"{path}: schema_version 必須是 {schema}") + return payload + + +def _validate_intake(payload: dict[str, Any]) -> None: + summary = payload.get("summary") or {} + expected_zero = ( + "request_sent_count", + "received_response_count", + "accepted_response_count", + "rejected_response_count", + "reviewer_queue_write_count", + "runtime_gate_count", + "action_button_count", + ) + for key in expected_zero: + if _int(summary.get(key)) != 0: + raise ValueError(f"intake.summary.{key} 必須維持 0") + boundaries = payload.get("execution_boundaries") or {} + for key in _REQUIRED_FALSE_BOUNDARIES: + if boundaries.get(key) is not False: + raise ValueError(f"intake.execution_boundaries.{key} 必須維持 false") + if boundaries.get("not_authorization") is not True: + raise ValueError("intake.execution_boundaries.not_authorization 必須是 true") + + +def _validate_coverage(payload: dict[str, Any]) -> None: + summary = payload.get("summary") or {} + if _int(summary.get("owner_response_received_count")) != 0: + raise ValueError("coverage owner_response_received_count 必須維持 0") + if _int(summary.get("owner_response_accepted_count")) != 0: + raise ValueError("coverage owner_response_accepted_count 必須維持 0") + if _int(summary.get("runtime_gate_count")) != 0: + raise ValueError("coverage runtime_gate_count 必須維持 0") + if _int(summary.get("action_button_count")) != 0: + raise ValueError("coverage action_button_count 必須維持 0") + + +def _validate_wazuh(payload: dict[str, Any]) -> None: + summary = payload.get("summary") or {} + for key in ( + "registry_export_received_count", + "registry_export_accepted_count", + "owner_evidence_received_count", + "owner_evidence_accepted_count", + "runtime_gate_count", + "active_response_authorized_count", + "host_write_authorized_count", + "secret_value_collection_allowed_count", + ): + if _int(summary.get(key)) != 0: + raise ValueError(f"wazuh.summary.{key} 必須維持 0") + boundaries = payload.get("execution_boundaries") or {} + for key, value in boundaries.items(): + if key == "not_authorization": + if value is not True: + raise ValueError("wazuh.execution_boundaries.not_authorization 必須是 true") + elif value is not False: + raise ValueError(f"wazuh.execution_boundaries.{key} 必須維持 false") + + +def _build_lane( + lane_id: str, + packet_by_category: dict[str, dict[str, Any]], + coverage_by_category: dict[str, dict[str, Any]], + wazuh: dict[str, Any], +) -> dict[str, Any]: + if lane_id == "secret_workflow_runner_source_control": + categories = [ + coverage_by_category.get("secret_metadata") or {}, + coverage_by_category.get("gitea_workflow_runner_source_control") or {}, + ] + priority = "P0" + control_tier = "C0" + status = "coverage_derived_waiting_owner_packet" + evidence_ref_count = sum(_len(category.get("evidence_refs")) for category in categories) + required_validation_count = sum(_len(category.get("required_validation")) for category in categories) + source_kind = "coverage_derived_intake_candidate" + elif lane_id == "wazuh_manager_registry": + summary = wazuh.get("summary") or {} + priority = "P0" + control_tier = "C0" + status = "wazuh_owner_evidence_preflight_ready" + evidence_ref_count = 0 + required_validation_count = _int(summary.get("reviewer_check_count")) + source_kind = "wazuh_owner_evidence_preflight" + else: + packet = packet_by_category.get(lane_id) + category = coverage_by_category.get(lane_id) or {} + priority = str((packet or category).get("priority") or "P0") + control_tier = str((packet or category).get("control_tier") or "C0") + status = "owner_packet_ready_waiting_dispatch_preflight" if packet else "coverage_derived_waiting_owner_packet" + evidence_ref_count = _len(category.get("evidence_refs")) + required_validation_count = _len((packet or {}).get("required_validation")) or _len( + category.get("required_validation") + ) + source_kind = "owner_packet_intake_preflight" if packet else "coverage_derived_intake_candidate" + + return { + "lane_id": lane_id, + "label": _PUBLIC_LABELS[lane_id], + "priority": priority, + "control_tier": control_tier, + "status": status, + "source_kind": source_kind, + "required_owner_fields": _OWNER_FIELDS, + "required_owner_field_count": len(_OWNER_FIELDS), + "required_evidence": _REQUIRED_EVIDENCE[lane_id], + "required_evidence_count": len(_REQUIRED_EVIDENCE[lane_id]), + "required_validation_count": required_validation_count, + "blocked_runtime_actions": _BLOCKED_RUNTIME_ACTIONS[lane_id], + "blocked_runtime_action_count": len(_BLOCKED_RUNTIME_ACTIONS[lane_id]), + "evidence_ref_count": evidence_ref_count, + "request_sent": False, + "owner_response_received": False, + "owner_response_accepted": False, + "owner_response_rejected": False, + "owner_response_quarantined": False, + "reviewer_queue_write": False, + "runtime_gate_open": False, + "action_buttons_allowed": False, + "secret_value_collection_allowed": False, + "not_authorization": True, + } + + +def _summary(intake: dict[str, Any], wazuh: dict[str, Any], lanes: list[dict[str, Any]]) -> dict[str, int]: + intake_summary = intake.get("summary") or {} + wazuh_summary = wazuh.get("summary") or {} + return { + "lane_count": len(lanes), + "owner_packet_source_lane_count": sum(1 for lane in lanes if lane["source_kind"] == "owner_packet_intake_preflight"), + "coverage_derived_lane_count": sum(1 for lane in lanes if lane["source_kind"] == "coverage_derived_intake_candidate"), + "wazuh_lane_count": sum(1 for lane in lanes if lane["source_kind"] == "wazuh_owner_evidence_preflight"), + "canonical_owner_field_count": len(_OWNER_FIELDS), + "required_owner_field_total": len(_OWNER_FIELDS) * len(lanes), + "dispatch_preflight_check_count": _int(intake_summary.get("dispatch_preflight_check_count")), + "reviewer_intake_lane_count": _int(intake_summary.get("reviewer_intake_lane_count")), + "blocked_request_count": len(_blocked_requests(intake, wazuh)), + "wazuh_required_field_count": _int(wazuh_summary.get("required_field_count")), + "wazuh_reviewer_check_count": _int(wazuh_summary.get("reviewer_check_count")), + "request_sent_count": 0, + "owner_response_received_count": 0, + "owner_response_accepted_count": 0, + "owner_response_rejected_count": 0, + "owner_response_quarantined_count": 0, + "reviewer_queue_write_count": 0, + "runtime_gate_count": 0, + "action_button_count": 0, + "host_write_authorized_count": 0, + "active_scan_authorized_count": 0, + "wazuh_live_query_authorized_count": 0, + "wazuh_active_response_authorized_count": 0, + "secret_value_collection_allowed_count": 0, + } + + +def _public_checks(items: Any) -> list[dict[str, Any]]: + checks = items if isinstance(items, list) else [] + return [ + { + "check_id": str(item.get("check_id") or ""), + "label": str(item.get("label") or ""), + "instruction": str(item.get("instruction") or ""), + "required": item.get("required") is True, + "gate_effect": "不增加 request / received / accepted / runtime gate。", + } + for item in checks + if isinstance(item, dict) + ] + + +def _public_reviewer_lanes(items: Any) -> list[dict[str, Any]]: + lanes = items if isinstance(items, list) else [] + return [ + { + "lane_id": str(item.get("lane_id") or ""), + "instruction": str(item.get("instruction") or ""), + "gate_effect": str(item.get("gate_effect") or ""), + } + for item in lanes + if isinstance(item, dict) + ] + + +def _blocked_requests(intake: dict[str, Any], wazuh: dict[str, Any]) -> list[str]: + blocked = set() + for item in intake.get("blocked_requests") or []: + if isinstance(item, str): + blocked.add(item) + for item in wazuh.get("forbidden_payloads") or []: + if isinstance(item, str): + blocked.add(item) + blocked.update( + { + "wazuh_api_live_query", + "wazuh_active_response", + "raw_wazuh_payload_store", + "internal_ip_public_display", + "agent_identity_public_display", + } + ) + return sorted(blocked) + + +def _execution_boundaries() -> dict[str, bool]: + boundaries = {key: False for key in sorted(_REQUIRED_FALSE_BOUNDARIES)} + boundaries.update( + { + "wazuh_api_live_query_authorized": False, + "wazuh_active_response_authorized": False, + "agent_identity_public_display_allowed": False, + "internal_ip_public_display_allowed": False, + "raw_wazuh_payload_storage_allowed": False, + "not_authorization": True, + } + ) + return boundaries + + +def _boundary_markers(summary: dict[str, int]) -> list[str]: + return [ + f"owner_evidence_intake_lane_count={summary['lane_count']}", + f"canonical_owner_field_count={summary['canonical_owner_field_count']}", + f"required_owner_field_total={summary['required_owner_field_total']}", + f"dispatch_preflight_check_count={summary['dispatch_preflight_check_count']}", + f"reviewer_intake_lane_count={summary['reviewer_intake_lane_count']}", + f"blocked_request_count={summary['blocked_request_count']}", + "request_sent_count=0", + "owner_response_received_count=0", + "owner_response_accepted_count=0", + "owner_response_quarantined_count=0", + "reviewer_queue_write_count=0", + "runtime_gate_count=0", + "action_button_count=0", + "host_write_authorized_count=0", + "active_scan_authorized_count=0", + "wazuh_live_query_authorized_count=0", + "wazuh_active_response_authorized_count=0", + "secret_value_collection_allowed_count=0", + "not_authorization=true", + ] + + +def _int(value: Any) -> int: + return value if isinstance(value, int) else 0 + + +def _len(value: Any) -> int: + return len(value) if isinstance(value, list) else 0 diff --git a/apps/api/tests/test_iwooos_owner_evidence_intake_preflight.py b/apps/api/tests/test_iwooos_owner_evidence_intake_preflight.py new file mode 100644 index 000000000..cff1f3e9e --- /dev/null +++ b/apps/api/tests/test_iwooos_owner_evidence_intake_preflight.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.iwooos import router +from src.services.iwooos_owner_evidence_intake_preflight import ( + load_latest_iwooos_owner_evidence_intake_preflight, +) + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_iwooos_owner_evidence_intake_preflight_prioritizes_p0_lanes() -> None: + payload = load_latest_iwooos_owner_evidence_intake_preflight() + + assert payload["schema_version"] == "iwooos_owner_evidence_intake_preflight_v1" + assert payload["status"] == "owner_evidence_intake_preflight_ready_no_runtime_action" + assert payload["summary"]["lane_count"] == 6 + assert payload["summary"]["canonical_owner_field_count"] == 9 + assert payload["summary"]["required_owner_field_total"] == 54 + assert payload["summary"]["dispatch_preflight_check_count"] == 9 + assert payload["summary"]["reviewer_intake_lane_count"] == 5 + + lane_ids = [lane["lane_id"] for lane in payload["lanes"]] + assert lane_ids == [ + "nginx_public_gateway", + "dns_tls_certbot", + "k8s_production_gitops", + "secret_workflow_runner_source_control", + "public_admin_api_runtime_config", + "wazuh_manager_registry", + ] + assert payload["lanes"][0]["label"] == "Nginx / 公開入口 / 反向代理" + assert payload["lanes"][-1]["label"] == "Wazuh manager registry / 逐主機矩陣" + + +def test_iwooos_owner_evidence_intake_preflight_keeps_runtime_boundaries_closed() -> None: + payload = load_latest_iwooos_owner_evidence_intake_preflight() + + summary = payload["summary"] + assert summary["request_sent_count"] == 0 + assert summary["owner_response_received_count"] == 0 + assert summary["owner_response_accepted_count"] == 0 + assert summary["owner_response_quarantined_count"] == 0 + assert summary["reviewer_queue_write_count"] == 0 + assert summary["runtime_gate_count"] == 0 + assert summary["action_button_count"] == 0 + assert summary["host_write_authorized_count"] == 0 + assert summary["active_scan_authorized_count"] == 0 + assert summary["wazuh_live_query_authorized_count"] == 0 + assert summary["wazuh_active_response_authorized_count"] == 0 + assert summary["secret_value_collection_allowed_count"] == 0 + + boundaries = payload["execution_boundaries"] + assert boundaries["not_authorization"] is True + for key, value in boundaries.items(): + if key == "not_authorization": + continue + assert value is False + + for lane in payload["lanes"]: + assert lane["request_sent"] is False + assert lane["owner_response_received"] is False + assert lane["owner_response_accepted"] is False + assert lane["reviewer_queue_write"] is False + assert lane["runtime_gate_open"] is False + assert lane["action_buttons_allowed"] is False + assert lane["secret_value_collection_allowed"] is False + + +def test_iwooos_owner_evidence_intake_preflight_api_is_public_safe() -> None: + response = _client().get("/api/v1/iwooos/owner-evidence-intake-preflight") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "iwooos_owner_evidence_intake_preflight_v1" + assert data["summary"]["lane_count"] == 6 + assert data["summary"]["runtime_gate_count"] == 0 + assert data["lanes"][0]["lane_id"] == "nginx_public_gateway" + assert data["lanes"][-1]["lane_id"] == "wazuh_manager_registry" + assert "192.168.0." not in response.text + assert "工作視窗" not in response.text + assert "批准!繼續" not in response.text + assert "source_thread_id" not in response.text + assert "owenhytsai/" not in response.text + assert "raw Wazuh payload" not in response.text + assert "WAZUH_API_PASSWORD" not in response.text diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f98d09c63..8b8e89fa9 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -21284,6 +21284,42 @@ } } }, + "ownerEvidenceIntakePreflight": { + "eyebrow": "負責人證據收件預檢", + "title": "Nginx、DNS、K8s、機密、執行器與 Wazuh 證據先進同一條收件線", + "subtitle": "此卡只讀 committed snapshot,把六條 P0 owner-provided redacted evidence lane 統一成可檢查欄位、拒收規則與 0 / false 邊界;不送 request、不收回覆、不寫 reviewer queue、不查 live host、不開 runtime action。", + "stateLabel": "收件來源", + "boundaryTitle": "負責人證據收件邊界", + "boundaryIntro": "以下鍵值固定:預檢可見不代表 request 已送出、回覆已收到、reviewer 已接受、Nginx 可 reload、ArgoCD 可 sync、workflow 可改、Wazuh 可 active response 或 Kali 可掃描。", + "apiStatus": { + "loading": "正在讀取只讀 API", + "ready": "只讀 API 已接上", + "fallback": "API 未回,使用靜態 fallback" + }, + "sourceKinds": { + "ownerPacket": "Owner packet 預檢", + "coverage": "覆蓋矩陣候選", + "wazuh": "Wazuh 證據預檢" + }, + "summary": { + "lanes": { + "label": "收件線", + "detail": "六條 P0 lane 進入同一個預檢。" + }, + "fields": { + "label": "必要欄位", + "detail": "owner role、decision、scope、脫敏 evidence、followup、rollback、window 與 validation。" + }, + "checks": { + "label": "送件前檢查", + "detail": "基線、snapshot、欄位、敏感 payload、執行要求與驗證計畫都要先檢查。" + }, + "runtimeGate": { + "label": "執行期", + "detail": "request、received、accepted、runtime、action button 全部維持 0。" + } + } + }, "highValueConfigControlCoverage": { "eyebrow": "高價值配置覆蓋矩陣", "title": "14 類重要配置已進入同一張控管矩陣", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index f98d09c63..8b8e89fa9 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -21284,6 +21284,42 @@ } } }, + "ownerEvidenceIntakePreflight": { + "eyebrow": "負責人證據收件預檢", + "title": "Nginx、DNS、K8s、機密、執行器與 Wazuh 證據先進同一條收件線", + "subtitle": "此卡只讀 committed snapshot,把六條 P0 owner-provided redacted evidence lane 統一成可檢查欄位、拒收規則與 0 / false 邊界;不送 request、不收回覆、不寫 reviewer queue、不查 live host、不開 runtime action。", + "stateLabel": "收件來源", + "boundaryTitle": "負責人證據收件邊界", + "boundaryIntro": "以下鍵值固定:預檢可見不代表 request 已送出、回覆已收到、reviewer 已接受、Nginx 可 reload、ArgoCD 可 sync、workflow 可改、Wazuh 可 active response 或 Kali 可掃描。", + "apiStatus": { + "loading": "正在讀取只讀 API", + "ready": "只讀 API 已接上", + "fallback": "API 未回,使用靜態 fallback" + }, + "sourceKinds": { + "ownerPacket": "Owner packet 預檢", + "coverage": "覆蓋矩陣候選", + "wazuh": "Wazuh 證據預檢" + }, + "summary": { + "lanes": { + "label": "收件線", + "detail": "六條 P0 lane 進入同一個預檢。" + }, + "fields": { + "label": "必要欄位", + "detail": "owner role、decision、scope、脫敏 evidence、followup、rollback、window 與 validation。" + }, + "checks": { + "label": "送件前檢查", + "detail": "基線、snapshot、欄位、敏感 payload、執行要求與驗證計畫都要先檢查。" + }, + "runtimeGate": { + "label": "執行期", + "detail": "request、received、accepted、runtime、action button 全部維持 0。" + } + } + }, "highValueConfigControlCoverage": { "eyebrow": "高價值配置覆蓋矩陣", "title": "14 類重要配置已進入同一張控管矩陣", diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index 6dcac352f..b96b16bd3 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -37,6 +37,8 @@ import { apiClient, type IwoooSHighValueConfigControlCoverageCategory, type IwoooSHighValueConfigControlCoverageResponse, + type IwoooSOwnerEvidenceIntakePreflightLane, + type IwoooSOwnerEvidenceIntakePreflightResponse, type IwoooSRuntimeSecurityReadbackResponse, type IwoooSSecurityControlCoverageDomain, type IwoooSSecurityControlCoverageResponse, @@ -414,6 +416,16 @@ type HighValueConfigControlCoverageDisplayItem = HighValueConfigControlCoverageI body?: string } +type OwnerEvidenceIntakePreflightDisplayItem = { + key: string + rank: string + title: string + state: string + body: string + icon: typeof ShieldCheck + tone: 'steady' | 'warn' | 'locked' +} + type CriticalConfigPriorityItem = { key: string rank: string @@ -2844,6 +2856,93 @@ const highValueConfigControlCoverageIconByCategoryId: Record = { + nginx_public_gateway: Route, + dns_tls_certbot: Cloud, + k8s_production_gitops: Workflow, + secret_workflow_runner_source_control: Lock, + public_admin_api_runtime_config: Code2, + wazuh_manager_registry: ShieldCheck, +} + +const ownerEvidenceIntakePreflightFallbackSummary = [ + { key: 'lanes', value: '6', icon: ListChecks, tone: 'steady' as const }, + { key: 'fields', value: '9', icon: FileText, tone: 'warn' as const }, + { key: 'checks', value: '9', icon: ClipboardCheck, tone: 'warn' as const }, + { key: 'runtimeGate', value: '0', icon: Lock, tone: 'locked' as const }, +] + +const ownerEvidenceIntakePreflightFallbackItems: OwnerEvidenceIntakePreflightDisplayItem[] = [ + { + key: 'nginx_public_gateway', + rank: 'P0', + title: 'Nginx / 公開入口 / 反向代理', + state: '等待脫敏證據', + body: '需要 live conf 參照、rendered diff、nginx -t、route smoke、維護窗口與 rollback owner;reload 仍為 0。', + icon: Route, + tone: 'warn', + }, + { + key: 'dns_tls_certbot', + rank: 'P0', + title: 'DNS / TLS / 憑證續期', + state: '等待 owner evidence', + body: '需要 domain / certificate path、ACME route smoke、HTTPS smoke、renewal window 與 rollback owner;renew 仍為 0。', + icon: Cloud, + tone: 'warn', + }, + { + key: 'k8s_production_gitops', + rank: 'P0', + title: 'K8s / ArgoCD / 正式環境宣告', + state: 'coverage-derived candidate', + body: '需要 ArgoCD health / sync / revision、manifest diff、Pending 摘要與 rollback revision;sync / kubectl 仍為 0。', + icon: Workflow, + tone: 'warn', + }, + { + key: 'secret_workflow_runner_source_control', + rank: 'P0', + title: '機密中繼資料 / 工作流程 / 執行器', + state: 'coverage-derived candidate', + body: '只收 secret name、workflow diff、runner attestation、run readback 與 log redaction;secret value / runner token 拒收。', + icon: Lock, + tone: 'locked', + }, + { + key: 'public_admin_api_runtime_config', + rank: 'P0', + title: '公開 / 後台 / API runtime config', + state: 'coverage-derived candidate', + body: '需要 affected route、auth boundary、API contract、frontend env / i18n redaction 與 desktop / mobile smoke。', + icon: Code2, + tone: 'warn', + }, + { + key: 'wazuh_manager_registry', + rank: 'P0', + title: 'Wazuh manager registry / 逐主機矩陣', + state: '等待 registry 脫敏矩陣', + body: '需要 manager registry 脫敏計數、逐主機 alias matrix、Dashboard API 狀態與缺席主機 owner decision。', + icon: ShieldCheck, + tone: 'locked', + }, +] + +const ownerEvidenceIntakePreflightFallbackBoundaries = [ + 'owner_evidence_intake_lane_count=6', + 'canonical_owner_field_count=9', + 'request_sent_count=0', + 'owner_response_received_count=0', + 'owner_response_accepted_count=0', + 'runtime_gate_count=0', + 'action_button_count=0', + 'host_write_authorized_count=0', + 'active_scan_authorized_count=0', + 'secret_value_collection_allowed_count=0', + 'not_authorization=true', +] + const highValueConfigControlCoverageBoundaries = [ 'high_value_config_control_coverage_frontstage_summary_count=4', 'high_value_config_control_coverage_frontstage_item_count=5', @@ -10304,6 +10403,212 @@ function IwoooSHighValueConfigOwnerPacketBoard() { ) } +function IwoooSOwnerEvidenceIntakePreflightBoard() { + const t = useTranslations('iwooos.ownerEvidenceIntakePreflight') + const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const } + const [data, setData] = useState(null) + const [failed, setFailed] = useState(false) + + useEffect(() => { + let mounted = true + + async function loadPreflight() { + setFailed(false) + try { + const payload = await apiClient.getIwoooSOwnerEvidenceIntakePreflight() + if (mounted) { + setData(payload) + } + } catch { + if (mounted) { + setData(null) + setFailed(true) + } + } + } + + loadPreflight() + return () => { + mounted = false + } + }, []) + + const summary = data?.summary + const summaryItems = [ + { + key: 'lanes', + value: summary ? String(summary.lane_count) : ownerEvidenceIntakePreflightFallbackSummary[0].value, + icon: ListChecks, + tone: 'steady' as const, + }, + { + key: 'fields', + value: summary ? String(summary.canonical_owner_field_count) : ownerEvidenceIntakePreflightFallbackSummary[1].value, + icon: FileText, + tone: 'warn' as const, + }, + { + key: 'checks', + value: summary ? String(summary.dispatch_preflight_check_count) : ownerEvidenceIntakePreflightFallbackSummary[2].value, + icon: ClipboardCheck, + tone: 'warn' as const, + }, + { + key: 'runtimeGate', + value: summary ? String(summary.runtime_gate_count) : ownerEvidenceIntakePreflightFallbackSummary[3].value, + icon: Lock, + tone: 'locked' as const, + }, + ] + const dynamicItems: OwnerEvidenceIntakePreflightDisplayItem[] = (data?.lanes ?? []) + .slice(0, 6) + .map((lane: IwoooSOwnerEvidenceIntakePreflightLane) => ({ + key: lane.lane_id, + rank: lane.priority, + title: lane.label, + state: lane.source_kind === 'owner_packet_intake_preflight' + ? t('sourceKinds.ownerPacket') + : lane.source_kind === 'wazuh_owner_evidence_preflight' + ? t('sourceKinds.wazuh') + : t('sourceKinds.coverage'), + body: lane.required_evidence.slice(0, 3).join(' / '), + icon: ownerEvidenceIntakePreflightIconByLaneId[lane.lane_id] ?? ShieldCheck, + tone: lane.runtime_gate_open ? 'steady' : lane.control_tier === 'C0' ? 'warn' : 'locked', + })) + const displayItems = dynamicItems.length ? dynamicItems : ownerEvidenceIntakePreflightFallbackItems + const boundaryMarkers = data?.boundary_markers?.length + ? data.boundary_markers + : ownerEvidenceIntakePreflightFallbackBoundaries + const statusTone: 'steady' | 'warn' | 'locked' = failed ? 'warn' : data ? 'locked' : 'warn' + + return ( +
+
+
+
+
+ + {t('eyebrow')} +
+

{t('title')}

+

+ {t('subtitle')} +

+
+ + {data ? t('apiStatus.ready') : failed ? t('apiStatus.fallback') : t('apiStatus.loading')} +
+
+ +
+ {summaryItems.map(item => { + const Icon = item.icon + return ( +
+
+ {t(`summary.${item.key}.label` as never)} + +
+
+ {item.value} +
+

+ {t(`summary.${item.key}.detail` as never)} +

+
+ ) + })} +
+
+ +
+ {displayItems.map(item => { + const Icon = item.icon + return ( +
+
+ + {item.rank} + + +
+
+
+ {item.title} +
+
+ {t('stateLabel')}:{item.state} +
+
+

+ {item.body} +

+
+ ) + })} +
+ +
+ + {t('boundaryTitle')} + +

+ {t('boundaryIntro')} +

+
+ {boundaryMarkers.map(item => ( + + {item} + + ))} +
+
+
+
+ ) +} + function IwoooSCriticalConfigPriorityBoard() { const t = useTranslations('iwooos.criticalConfigPriority') const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const } @@ -22792,6 +23097,7 @@ export default function IwoooSPage({ params }: { params: { locale: string } }) { + + status: string + mode: string + summary: { + lane_count: number + owner_packet_source_lane_count: number + coverage_derived_lane_count: number + wazuh_lane_count: number + canonical_owner_field_count: number + required_owner_field_total: number + dispatch_preflight_check_count: number + reviewer_intake_lane_count: number + blocked_request_count: number + wazuh_required_field_count: number + wazuh_reviewer_check_count: number + request_sent_count: number + owner_response_received_count: number + owner_response_accepted_count: number + owner_response_rejected_count: number + owner_response_quarantined_count: number + reviewer_queue_write_count: number + runtime_gate_count: number + action_button_count: number + host_write_authorized_count: number + active_scan_authorized_count: number + wazuh_live_query_authorized_count: number + wazuh_active_response_authorized_count: number + secret_value_collection_allowed_count: number + } + lanes: IwoooSOwnerEvidenceIntakePreflightLane[] + dispatch_preflight_checks: Array<{ + check_id: string + label: string + instruction: string + required: boolean + gate_effect: string + }> + reviewer_intake_lanes: Array<{ + lane_id: string + instruction: string + gate_effect: string + }> + blocked_requests: string[] + execution_boundaries: Record + boundary_markers: string[] + no_false_green_rules: string[] + source_refs: string[] +} + async function handleResponse(response: Response): Promise { if (!response.ok) { const error = await response.json().catch(() => ({})) @@ -440,6 +518,11 @@ export const apiClient = { return handleResponse(res) }, + async getIwoooSOwnerEvidenceIntakePreflight() { + const res = await fetch(`${API_BASE_URL}/iwooos/owner-evidence-intake-preflight`, { cache: 'no-store' }) + return handleResponse(res) + }, + // Agent async getAgentStatus() { const res = await fetch(`${API_BASE_URL}/agent/status`) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index fb4e490ac..e1a301f00 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -46928,3 +46928,44 @@ production browser smoke: - `P0-01` owner-provided redacted evidence intake:把 Nginx / DNS / K8s / secret / runner / Wazuh registry 的 owner response 收件與拒收規則接到同一條只讀預檢。 - `P0-02` Wazuh manager registry accepted:只讀交叉驗收所有 expected host / product / agent scope,不把 dashboard 可開或 API 200 當作全主機納管恢復。 - `P0-03` Nginx live diff evidence gate:收 owner-provided redacted live evidence、rendered diff、`nginx -t` result ref、route smoke ref、rollback owner;未完成前 reload gate 維持 0。 + +## 2026-06-27 — 13:44 IwoooS 負責人脫敏證據收件預檢本地完成 + +**時間與來源**: +- 2026-06-27 13:44 Asia/Taipei。 +- 啟動前已同步 `gitea/main=f88d89fc3 fix(api): backfill ansible repair candidates`;本地工作樹以非破壞方式 rebase 到最新主線,未 force push。 +- 來源:`docs/security/high-value-config-owner-packet-intake-preflight.snapshot.json`、`docs/security/high-value-config-control-coverage.snapshot.json`、`docs/security/wazuh-agent-visibility-owner-evidence-preflight.snapshot.json`、新增 `apps/api/src/services/iwooos_owner_evidence_intake_preflight.py`、`GET /api/v1/iwooos/owner-evidence-intake-preflight`、IwoooS 前台新卡與 `scripts/security/iwooos-config-control-guard.py`。 + +**完成內容**: +- 新增 IwoooS 公開安全只讀 API:`GET /api/v1/iwooos/owner-evidence-intake-preflight`。 +- 將六條 P0 owner-provided redacted evidence lane 統一成同一個收件預檢:`nginx_public_gateway`、`dns_tls_certbot`、`k8s_production_gitops`、`secret_workflow_runner_source_control`、`public_admin_api_runtime_config`、`wazuh_manager_registry`。 +- API 僅回傳欄位需求、拒收規則、公開安全 label 與 0 / false 邊界;不送 owner request、不收 owner response、不寫 reviewer queue、不讀 secret、不查 live host、不查 Wazuh API、不啟動 runtime action。 +- 前台新增「負責人證據收件預檢」卡,讀取只讀 API,API 未回才使用靜態 fallback;`zh-TW` 與目前鏡像訊息皆為繁體中文。 +- `scripts/security/iwooos-config-control-guard.py` 加入 owner evidence intake service / router / test / api-client / page / i18n 標記檢查,避免後續改版拔掉這條 P0 收件線。 + +**本地驗證結果**: +- `python3 -m py_compile apps/api/src/services/iwooos_owner_evidence_intake_preflight.py apps/api/src/services/iwooos_high_value_config_control_coverage.py apps/api/src/api/v1/iwooos.py scripts/security/iwooos-config-control-guard.py`:通過。 +- `python3 -m json.tool apps/web/messages/zh-TW.json`、`python3 -m json.tool apps/web/messages/en.json`:通過。 +- `env DATABASE_URL=postgresql://test:test@localhost:5432/test pytest apps/api/tests/test_iwooos_owner_evidence_intake_preflight.py apps/api/tests/test_iwooos_high_value_config_control_coverage.py apps/api/tests/test_iwooos_security_control_coverage.py apps/api/tests/test_iwooos_runtime_security_readback.py`:`15 passed`。 +- `python3 scripts/security/iwooos-config-control-guard.py --root .`:`IWOOOS_CONFIG_CONTROL_GUARD_OK`。 +- `python3 scripts/security/iwooos-frontend-display-redaction-guard.py --root .`:`IWOOOS_FRONTEND_DISPLAY_REDACTION_GUARD_OK`。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。 +- `pnpm --dir apps/web typecheck`:通過。 +- `git diff --check`:通過。 + +**完成度與同步狀態**: +- 本段「owner-provided redacted evidence intake 預檢 API / 前台」:`0% -> 80%`,本地實作與驗證完成,待 commit / push / CD / production API readback / desktop / mobile browser smoke。 +- IwoooS 整體:保守維持約 `65%`,因為此段建立收件線,但 owner response received / accepted 與 runtime authorization 仍未發生。 +- P0 收件治理:`40% -> 55%`,六條 P0 lane 已能被 API 與前台共同讀回,下一步才是正式站驗證與 owner-provided evidence 收件。 + +**仍維持 0 / false**: +- `request_sent_count=0`、`owner_response_received_count=0`、`owner_response_accepted_count=0`、`runtime_gate_count=0`、`action_button_count=0`。 +- `runtime_execution_authorized=false`、`host_write_authorized=false`、`nginx_reload_authorized=false`、`public_gateway_reload_authorized=false`、`dns_tls_change_authorized=false`、`certbot_renew_authorized=false`、`argocd_sync_authorized=false`、`kubectl_action_authorized=false`、`workflow_modification_authorized=false`、`runner_change_authorized=false`、`secret_value_collection_allowed=false`、`wazuh_live_query_authorized=false`、`wazuh_active_response_authorized=false`、`active_scan_authorized=false`。 + +**做過的命令類型**: +- 寫入:repo API service / router / tests / frontend API client / IwoooS page / i18n / guard / LOGBOOK。 +- 只讀:committed snapshot readback、本地測試與 source guard。 +- 未做:沒有 host / Docker / systemd / Nginx / firewall / K8s / DB / Wazuh runtime 寫操作;沒有讀 secret 明文;沒有 Nginx reload;沒有 ArgoCD sync;沒有 workflow 修改;沒有 Wazuh active response;沒有 Kali active scan。 + +**下一步**: +- commit / push 後等待 Gitea CD;CD 完成後驗證正式 API schema、六條 lane、Wazuh lane 可見、0 / false 邊界、禁止字串不出現,並做 `/zh-TW/iwooos` 桌機 / 手機無水平溢出確認。 diff --git a/scripts/security/iwooos-config-control-guard.py b/scripts/security/iwooos-config-control-guard.py index 39e086946..9affa203d 100644 --- a/scripts/security/iwooos-config-control-guard.py +++ b/scripts/security/iwooos-config-control-guard.py @@ -1284,6 +1284,36 @@ def validate_high_value_config_api_path(root: Path) -> None: "apps/web/src/app/[locale]/iwooos/page.tsx", "apiClient.getIwoooSHighValueConfigControlCoverage", ) + assert_file_contains( + root, + "apps/api/src/services/iwooos_owner_evidence_intake_preflight.py", + "iwooos_owner_evidence_intake_preflight_v1", + ) + assert_file_contains( + root, + "apps/api/src/api/v1/iwooos.py", + "/api/v1/iwooos/owner-evidence-intake-preflight", + ) + assert_file_contains( + root, + "apps/api/tests/test_iwooos_owner_evidence_intake_preflight.py", + "test_iwooos_owner_evidence_intake_preflight_api_is_public_safe", + ) + assert_file_contains( + root, + "apps/web/src/lib/api-client.ts", + "getIwoooSOwnerEvidenceIntakePreflight", + ) + assert_file_contains( + root, + "apps/web/src/app/[locale]/iwooos/page.tsx", + "apiClient.getIwoooSOwnerEvidenceIntakePreflight", + ) + assert_file_contains( + root, + "apps/web/messages/zh-TW.json", + '"ownerEvidenceIntakePreflight"', + ) def validate(root: Path) -> None: