diff --git a/.gitignore b/.gitignore index 5c95dbe..c09a6ea 100644 --- a/.gitignore +++ b/.gitignore @@ -142,6 +142,7 @@ docker-compose.override.yml # CD-generated governance receipts (deployed evidence, not source) governance/security_governance_source_receipt.json +governance/security_governance_runtime_receipt.json # SSL 憑證 ssl/ diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 997199a..aea8944 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.772 (Security governance automation review) +> **當前版本**: V10.773 (Security governance automation review) > **最後更新**: 2026-07-10 > **治理基準**: `global_product_governance_v2` + ADR-038 diff --git a/config.py b/config.py index 84affc4..a17bd0b 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.772" +SYSTEM_VERSION = "V10.773" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index f60c69f..c27dc72 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-07-10 (台北時間) > **狀態**: 🟠 Partial。四 Agent、PixelRAG、MCP/RAG registry、PChome controlled-preview families 與多項 smoke/readback 已建立;但 access control、database identity/RBAC、full asset reconciliation、software supply chain、same-run controlled apply、restore drill、internal RAG canary 與 MCP/RAG runtime closure 尚未全部完成。任何局部 receipt 不得代表整體閉環完成。 -> **適用版本**: V10.772 +> **適用版本**: V10.773 --- diff --git a/docs/guides/ai_automation_mainline_work_items.md b/docs/guides/ai_automation_mainline_work_items.md index 3e53a8d..74757ac 100644 --- a/docs/guides/ai_automation_mainline_work_items.md +++ b/docs/guides/ai_automation_mainline_work_items.md @@ -16,7 +16,7 @@ | Order | ID | Status | Work item | Exit evidence / next machine action | |---:|---|---|---|---| -| 1 | `SEC-P0-001` | In progress | Deny-by-default route access control | Central policy now protects vendor/import/CI-CD/crawler/category/log/operator and sales-detail surfaces; every anonymous GET is explicitly allowlisted. Exit: anonymous production matrix passes and `/metrics` moves behind edge/internal auth. Next: `verify_production_anonymous_matrix_and_move_metrics_behind_edge_auth`. | +| 1 | `SEC-P0-001` | In progress | Deny-by-default route access control | Production anonymous matrix now denies sensitive routes and all browser headers/Secure cookie pass. Remaining exit: move `/metrics` behind edge/internal auth without breaking Prometheus. Next: `move_metrics_behind_edge_auth_with_prometheus_canary`. | | 2 | `SEC-P0-002` | Not started | Database identity + least-privilege RBAC | Replace shared admin password, default-admin fallback, process-local lockout and spoofable proxy identity. Exit: shadow auth, role matrix, durable audit/lockout, session revocation and canary cutover. | | 3 | `SEC-P0-003` | In progress | Webhook trust and replay protection | Telegram secret-token verification code exists; production secret activation remains unproven. Exit: secret provisioned outside source, required mode enabled, invalid-secret 401 and authorized callback canary pass. | | 4 | `SUPPLY-P0-001` | In progress | Gitea-only secure software supply chain | Gitea-native checkout, secret-safe `.dockerignore`, commit-bound source receipt and governance gate are active. Exit: exact dependency lock, internal SAST/SCA/secret scan, SBOM, image digest/provenance, vulnerability SLA and production digest readback. | @@ -25,7 +25,7 @@ | 7 | `RAG-P0-001` | Not started | Internal RAG candidate canary | V10.770 stops at candidate preview. Exit: bounded pgvector candidate canary, signature/readback/feedback receipt, no price write and no premature `ai_insights` promotion. Next: `run_internal_rag_candidate_canary`. | | 8 | `MCP-P0-001` | In progress | MCP/RAG production runtime closure | Registry and smoke wiring exist, but runtime flags/ports must be live. Exit: MCP servers healthy, router enabled, RAG enabled, approved caller/tool boundary and production query canary pass. | | 9 | `SEC-P0-004` | Not started | Security operations lifecycle and metrics | Add durable security incident state and publish MTTA, MTTR, recurrence, false positive, human intervention, verifier pass, rollback and freshness. Exit: detect-to-learn production receipt. | -| 10 | `REL-P0-001` | In progress | Formal deploy and visible proof discipline | Every functional change bumps version, passes Gitea CD, deploys only three app containers, leaves `momo-db` untouched, and reads back `/health`, new APIs and visible UI/security behavior. | +| 10 | `REL-P0-001` | In progress | Formal deploy and visible proof discipline | V10.773 records the controlled production fallback with `momo-db` unchanged; Gitea CD #1095 remains waiting because no `ewoooc-host` runner is online. Exit: restore a dedicated EwoooC-only runner, replay the same commit and close CD/readback automatically. | ## P1 diff --git a/governance/ewoooc_asset_inventory.json b/governance/ewoooc_asset_inventory.json index 6b54fcf..a7976e7 100644 --- a/governance/ewoooc_asset_inventory.json +++ b/governance/ewoooc_asset_inventory.json @@ -294,7 +294,7 @@ "canonical_id": "observability:ewoooc:security-runtime", "asset_type": "logs_metrics_traces_alerts", "owner_lane": "observability", - "source_truth": "Prometheus, Grafana, logs, alerts and Telegram receipts", + "source_truth": "Prometheus, Grafana, logs, alerts, security runtime receipt and Telegram receipts", "runtime_identity": "/metrics plus AI automation smoke", "signal_freshness": {"source": "scrape and alert receipt", "runtime_target": "security/governance families", "max_age_minutes": 15}, "policy": ["no public operational logs", "lifecycle receipts", "freshness SLO"], diff --git a/services/security_governance_review_service.py b/services/security_governance_review_service.py index b43cbe5..fa0f2f2 100644 --- a/services/security_governance_review_service.py +++ b/services/security_governance_review_service.py @@ -21,6 +21,7 @@ ROOT = Path(__file__).resolve().parents[1] ASSET_INVENTORY_PATH = Path("governance/ewoooc_asset_inventory.json") QUALITY_BASELINE_PATH = Path("governance/security_governance_review_baseline.json") SOURCE_RECEIPT_PATH = Path("governance/security_governance_source_receipt.json") +RUNTIME_RECEIPT_PATH = Path("governance/security_governance_runtime_receipt.json") MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} AUTH_DECORATOR_MARKERS = ( @@ -294,6 +295,60 @@ def _source_receipt(root: Path) -> dict[str, Any]: } +def _runtime_receipt(root: Path) -> dict[str, Any]: + path = root / RUNTIME_RECEIPT_PATH + try: + payload = json.loads(_read(path)) + except (TypeError, json.JSONDecodeError): + payload = {} + access = payload.get("access_control") if isinstance(payload, dict) else {} + database = payload.get("database") if isinstance(payload, dict) else {} + post_verifier = payload.get("post_verifier") if isinstance(payload, dict) else {} + cd = payload.get("cd") if isinstance(payload, dict) else {} + access = access if isinstance(access, dict) else {} + database = database if isinstance(database, dict) else {} + post_verifier = post_verifier if isinstance(post_verifier, dict) else {} + cd = cd if isinstance(cd, dict) else {} + source_commit = str(payload.get("source_commit") or "") if isinstance(payload, dict) else "" + commit_shape_valid = len(source_commit) == 40 and all( + char in "0123456789abcdefABCDEF" for char in source_commit + ) + security_readback_passed = bool( + payload.get("receipt_kind") == "security_governance_runtime_receipt" + and commit_shape_valid + and access.get("status") == "pass" + and access.get("sensitive_routes_denied") is True + and access.get("security_headers_passed") is True + and access.get("secure_cookie") is True + and database.get("unchanged") is True + and post_verifier.get("health_status") == "healthy" + and post_verifier.get("governance_gate") == "pass" + ) if isinstance(payload, dict) else False + return { + "path": RUNTIME_RECEIPT_PATH.as_posix(), + "available": bool(payload), + "trace_id": payload.get("trace_id") if isinstance(payload, dict) else None, + "run_id": payload.get("run_id") if isinstance(payload, dict) else None, + "work_item_id": payload.get("work_item_id") if isinstance(payload, dict) else None, + "source_commit": source_commit, + "source_commit_shape_valid": commit_shape_valid, + "deployed_version": payload.get("deployed_version") if isinstance(payload, dict) else None, + "deployment_mode": payload.get("deployment_mode") if isinstance(payload, dict) else None, + "security_readback_passed": security_readback_passed, + "sensitive_route_count": int(access.get("sensitive_route_count") or 0), + "denied_route_count": int(access.get("denied_route_count") or 0), + "metrics_public": bool(access.get("metrics_public")), + "database_unchanged": bool(database.get("unchanged")), + "health_status": post_verifier.get("health_status"), + "governance_gate": post_verifier.get("governance_gate"), + "cd_run_id": cd.get("run_id"), + "cd_status": cd.get("status"), + "cd_blocker": cd.get("blocker"), + "learning_recorded": bool(payload.get("learning_recorded")) if isinstance(payload, dict) else False, + "next_machine_action": payload.get("next_machine_action") if isinstance(payload, dict) else None, + } + + def _docker_build_context_posture(root: Path) -> dict[str, Any]: path = root / ".dockerignore" patterns = { @@ -415,9 +470,22 @@ def _check( } -def _work_items(checks: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: +def _work_items( + checks: dict[str, dict[str, Any]], + runtime_receipt: dict[str, Any], +) -> list[dict[str, Any]]: + access_next_action = ( + "move_metrics_behind_edge_auth_with_prometheus_canary" + if runtime_receipt["security_readback_passed"] + else "verify_production_anonymous_matrix_and_move_metrics_behind_edge_auth" + ) + release_next_action = ( + "restore_dedicated_ewoooc_runner_then_replay_waiting_cd" + if runtime_receipt["security_readback_passed"] + else "deploy_v10_773_then_read_back_security_matrix_and_container_identity" + ) return [ - {"order": 1, "id": "SEC-P0-001", "priority": "P0", "status": "in_progress", "lane": "deny_by_default_access", "objective": "Close all sensitive and mutating routes behind central auth or signed internal transport.", "next_machine_action": "verify_production_anonymous_matrix_and_move_metrics_behind_edge_auth"}, + {"order": 1, "id": "SEC-P0-001", "priority": "P0", "status": "in_progress", "lane": "deny_by_default_access", "objective": "Close all sensitive and mutating routes behind central auth or signed internal transport.", "next_machine_action": access_next_action}, {"order": 2, "id": "SEC-P0-002", "priority": "P0", "status": "not_started", "lane": "identity_rbac", "objective": "Replace the shared admin password with database-backed identities, least-privilege RBAC, durable lockout and auditable sessions.", "next_machine_action": "inventory_active_users_then_shadow_database_auth_before_cutover"}, {"order": 3, "id": "SEC-P0-003", "priority": "P0", "status": "in_progress", "lane": "webhook_trust", "objective": "Require Telegram and internal webhook transport secrets with replay protection.", "next_machine_action": "provision_telegram_webhook_secret_then_enable_required_mode_and_canary"}, {"order": 4, "id": "SUPPLY-P0-001", "priority": "P0", "status": "in_progress", "lane": "software_supply_chain", "objective": "Use Gitea-only checkout, exact locks, SAST/SCA/secret scan, SBOM, image digest and provenance gates.", "next_machine_action": "add_internal_scanner_artifacts_and_exact_dependency_lock"}, @@ -426,7 +494,7 @@ def _work_items(checks: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: {"order": 7, "id": "RAG-P0-001", "priority": "P0", "status": "not_started", "lane": "internal_rag_canary", "objective": "Canary PixelRAG candidate knowledge into internal pgvector RAG without price or ai_insights promotion.", "next_machine_action": "run_internal_rag_candidate_canary"}, {"order": 8, "id": "MCP-P0-001", "priority": "P0", "status": "in_progress", "lane": "mcp_rag_runtime", "objective": "Enable and verify MCP/RAG runtime health rather than registry-only readiness.", "next_machine_action": "close_mcp_ports_router_and_rag_runtime_readback"}, {"order": 9, "id": "SEC-P0-004", "priority": "P0", "status": "not_started", "lane": "security_operations", "objective": "Publish security MTTA/MTTR, recurrence, false-positive, verifier and rollback metrics with incident closure receipts.", "next_machine_action": "add_security_event_metrics_and_incident_lifecycle_store"}, - {"order": 10, "id": "REL-P0-001", "priority": "P0", "status": "in_progress", "lane": "release_readback", "objective": "Separate source, test, CD, production runtime and visible proof for every release without touching momo-db.", "next_machine_action": "deploy_v10_771_then_read_back_security_matrix_and_container_identity"}, + {"order": 10, "id": "REL-P0-001", "priority": "P0", "status": "in_progress", "lane": "release_readback", "objective": "Separate source, test, CD, production runtime and visible proof for every release without touching momo-db.", "next_machine_action": release_next_action}, {"order": 11, "id": "RES-P1-001", "priority": "P1", "status": "not_started", "lane": "resilience", "objective": "Automate checksum, offsite backup and isolated restore drills with measured RPO/RTO.", "next_machine_action": "build_non_destructive_restore_drill_receipt"}, {"order": 12, "id": "PLAT-P1-001", "priority": "P1", "status": "not_started", "lane": "container_hardening", "objective": "Run application containers as non-root with minimal capabilities and read-only filesystems where compatible.", "next_machine_action": "shadow_nonroot_image_then_canary_three_app_containers"}, {"order": 13, "id": "APPSEC-P1-001", "priority": "P1", "status": "in_progress", "lane": "browser_security", "objective": "Move CSP from report-only to enforced and remove unsafe DOM/inline sinks by measured rollout.", "next_machine_action": "collect_csp_violations_then_replace_high_risk_innerhtml_sinks"}, @@ -477,6 +545,7 @@ def build_security_governance_review( supply_chain["source_chain_ready"] = True dependencies = _dependency_posture(scan_root) quality = _quality_baseline(scan_root) + runtime_receipt = _runtime_receipt(scan_root) large_files = _large_python_files(scan_root) app_source = _read(scan_root / "app.py") alert_source = _read(scan_root / "routes" / "alert_routes.py") @@ -582,6 +651,23 @@ def build_security_governance_review( "enforce_csp_after_report_only_violation_cleanup", release_blocking=bool(missing_headers) or not session_secure_default, ), + _check( + "PR.AA-production-security-readback", + "PROTECT/DETECT", + "pass" if runtime_receipt["security_readback_passed"] else "partial", + "critical", + ( + f"Production security readback passed for {runtime_receipt['denied_route_count']}/{runtime_receipt['sensitive_route_count']} sensitive routes; database identity remained unchanged." + if runtime_receipt["security_readback_passed"] + else "Production access, header, cookie, container and database readback receipt is not complete." + ), + runtime_receipt, + ( + "move_metrics_behind_edge_auth_with_prometheus_canary" + if runtime_receipt["security_readback_passed"] + else "write_commit_bound_production_security_runtime_receipt" + ), + ), _check( "PR.IR-autoheal-resource-boundary", "PROTECT/RESPOND", @@ -706,6 +792,7 @@ def build_security_governance_review( 1, ) runtime_checks = [ + checks_by_id["PR.AA-production-security-readback"], checks_by_id["DE.CM-mcp-rag-runtime"], checks_by_id["AI.RAG-internal-candidate-canary"], checks_by_id["RC.RP-restore-drill"], @@ -742,6 +829,7 @@ def build_security_governance_review( }, "checks": checks, "quality_baseline": quality, + "runtime_receipt": runtime_receipt, "source_receipt": { key: value for key, value in source_receipt.items() if key != "_checks" }, @@ -754,7 +842,7 @@ def build_security_governance_review( ], "debt_does_not_equal_release_pass": True, }, - "work_items": _work_items(checks_by_id), + "work_items": _work_items(checks_by_id, runtime_receipt), "safety": { "read_only": True, "network_calls": False, @@ -763,7 +851,11 @@ def build_security_governance_review( "secret_reads": False, "external_side_effects": False, }, - "next_machine_action": "verify_production_anonymous_matrix_and_move_metrics_behind_edge_auth", + "next_machine_action": ( + "move_metrics_behind_edge_auth_with_prometheus_canary" + if runtime_receipt["security_readback_passed"] + else "verify_production_anonymous_matrix_and_move_metrics_behind_edge_auth" + ), } diff --git a/tests/test_security_governance_review_service.py b/tests/test_security_governance_review_service.py index 671b99c..a238624 100644 --- a/tests/test_security_governance_review_service.py +++ b/tests/test_security_governance_review_service.py @@ -127,6 +127,57 @@ def test_runtime_subset_uses_commit_bound_source_receipt(tmp_path): assert policy["evidence"]["evidence_source"] == "cd_source_receipt" +def test_runtime_receipt_advances_access_readback_without_hiding_remaining_debt(tmp_path): + from services.security_governance_review_service import ( + _runtime_receipt, + ) + + governance = tmp_path / "governance" + governance.mkdir() + payload = { + "receipt_kind": "security_governance_runtime_receipt", + "trace_id": "secgov-test", + "run_id": "secgov-test-run", + "work_item_id": "SEC-P0-001", + "source_commit": "c" * 40, + "deployed_version": "V10.773", + "deployment_mode": "controlled_manual_fallback", + "access_control": { + "status": "pass", + "sensitive_routes_denied": True, + "security_headers_passed": True, + "secure_cookie": True, + "sensitive_route_count": 8, + "denied_route_count": 8, + "metrics_public": True, + }, + "database": {"unchanged": True}, + "post_verifier": { + "health_status": "healthy", + "governance_gate": "pass", + }, + "cd": { + "run_id": 1095, + "status": "waiting", + "blocker": "no_ewoooc_host_runner", + }, + "learning_recorded": False, + "next_machine_action": "move_metrics_behind_edge_auth_with_prometheus_canary", + } + (governance / "security_governance_runtime_receipt.json").write_text( + json.dumps(payload, ensure_ascii=False), + encoding="utf-8", + ) + + receipt = _runtime_receipt(tmp_path) + + assert receipt["security_readback_passed"] is True + assert receipt["database_unchanged"] is True + assert receipt["metrics_public"] is True + assert receipt["cd_status"] == "waiting" + assert receipt["learning_recorded"] is False + + def test_sensitive_blueprint_policy_denies_anonymous_api(monkeypatch): import auth from services.http_access_policy_service import enforce_http_access_policy