feat(governance): persist production security readback
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-10 19:40:04 +08:00
parent b2be34a923
commit b4284693e4
8 changed files with 155 additions and 11 deletions

1
.gitignore vendored
View File

@@ -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/

View File

@@ -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

View File

@@ -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 # 用於模板顯示

View File

@@ -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
---

View File

@@ -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

View File

@@ -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"],

View File

@@ -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"
),
}

View File

@@ -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