feat(governance): persist production security readback
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user