diff --git a/apps/api/src/services/github_target_private_backup_evidence_gate.py b/apps/api/src/services/github_target_private_backup_evidence_gate.py index 0e0341193..db1286f3e 100644 --- a/apps/api/src/services/github_target_private_backup_evidence_gate.py +++ b/apps/api/src/services/github_target_private_backup_evidence_gate.py @@ -93,6 +93,14 @@ def build_github_target_private_backup_evidence_gate( } owner_summary = _dict(owner_response.get("summary")) owner_response_intake_readiness = _owner_response_intake_readiness(owner_response) + safe_credential_evidence_intake_readiness = ( + _safe_credential_evidence_intake_readiness( + owner_response=owner_response, + required_target_count=len( + [row for row in decisions if row.get("approval_required") is True] + ), + ) + ) owner_template_by_repo = { str(row.get("github_repo")): _dict(row) for row in _list(owner_response.get("response_templates")) @@ -214,6 +222,25 @@ def build_github_target_private_backup_evidence_gate( - private_backup_verified_count, "safe_credential_required_count": len(approval_required_targets), "safe_credential_accepted_evidence_count": 0, + "safe_credential_evidence_intake_ready": safe_credential_evidence_intake_readiness[ + "intake_ready" + ], + "safe_credential_required_redacted_evidence_ref_count": safe_credential_evidence_intake_readiness[ + "required_redacted_evidence_ref_count" + ], + "safe_credential_evidence_ref_rule_count": safe_credential_evidence_intake_readiness[ + "evidence_ref_rule_count" + ], + "safe_credential_redaction_example_count": safe_credential_evidence_intake_readiness[ + "redaction_example_count" + ], + "safe_credential_forbidden_payload_count": safe_credential_evidence_intake_readiness[ + "forbidden_payload_count" + ], + "safe_credential_quarantine_lane_count": safe_credential_evidence_intake_readiness[ + "quarantine_lane_count" + ], + "safe_credential_raw_payload_storage_allowed": False, "owner_response_received_count": _int( owner_summary.get("received_response_count") ), @@ -264,6 +291,7 @@ def build_github_target_private_backup_evidence_gate( "public_repo_allowed": False, }, "owner_response_intake_readiness": owner_response_intake_readiness, + "safe_credential_evidence_intake_readiness": safe_credential_evidence_intake_readiness, "targets": targets, "acceptance_requirements": _acceptance_requirements(owner_response), "rejection_rules": _rejection_rules(owner_response), @@ -367,6 +395,21 @@ def _build_target( "private_visibility_owner_evidence_ref": connector_readback.get("evidence_ref"), "safe_credential_evidence_status": "not_collected", "safe_credential_evidence_ref": None, + "safe_credential_evidence_intake_ready": approval_required, + "safe_credential_evidence_submission_status": "waiting_redacted_evidence_ref" + if approval_required + else "not_required", + "safe_credential_required_redacted_evidence_ref": approval_required, + "safe_credential_allowed_evidence_ref_types": [ + "repo_path", + "snapshot_path", + "redacted_metadata_pointer", + ] + if approval_required + else [], + "safe_credential_raw_payload_storage_allowed": False, + "safe_credential_private_clone_url_collection_allowed": False, + "safe_credential_secret_value_collection_allowed": False, "owner_response_template_id": owner_response_template.get("template_id"), "owner_response_submission_status": "waiting_owner_response" if approval_required @@ -687,6 +730,9 @@ def _require_owner_response_intake_consistency( _dict(row) for row in _list(payload.get("intake_preflight_checks")) ] acceptance_checks = [_dict(row) for row in _list(payload.get("acceptance_checks"))] + redaction_examples = [ + _dict(row) for row in _list(payload.get("owner_response_redaction_examples")) + ] summary = _dict(payload.get("summary")) if ( @@ -712,6 +758,10 @@ def _require_owner_response_intake_consistency( raise ValueError(f"{label}: intake preflight check count must match checks") if _int(summary.get("acceptance_check_count")) != len(acceptance_checks): raise ValueError(f"{label}: acceptance check count must match checks") + if _int(summary.get("owner_response_redaction_example_count")) != len( + redaction_examples + ): + raise ValueError(f"{label}: redaction example count must match examples") template_ids = { str(row.get("template_id")) for row in templates if row.get("template_id") @@ -723,12 +773,18 @@ def _require_owner_response_intake_consistency( ) executable_rows = [ - str(row.get("template_id") or row.get("check_id") or "unknown") + str( + row.get("template_id") + or row.get("check_id") + or row.get("example_id") + or "unknown" + ) for row in [ *templates, *collection_checks, *preflight_checks, *acceptance_checks, + *redaction_examples, ] if row.get("execution_authorized") is not False ] @@ -737,6 +793,31 @@ def _require_owner_response_intake_consistency( f"{label}: owner response intake rows must remain non-executing: {executable_rows}" ) + raw_payload_examples = [ + str(row.get("example_id") or "unknown") + for row in redaction_examples + if row.get("stored_raw_payload_allowed") is not False + ] + if raw_payload_examples: + raise ValueError( + f"{label}: redaction examples must not allow raw payload storage: {raw_payload_examples}" + ) + + required_forbidden_payloads = { + "token_value", + "secret_value", + "private_clone_url_credential", + "repo_archive", + "git_object_pack", + } + missing_forbidden_payloads = sorted( + required_forbidden_payloads - set(_strings(packet.get("forbidden_payloads"))) + ) + if missing_forbidden_payloads: + raise ValueError( + f"{label}: forbidden payloads missing safe credential blockers: {missing_forbidden_payloads}" + ) + def _owner_response_intake_readiness(owner_response: dict[str, Any]) -> dict[str, Any]: summary = _dict(owner_response.get("summary")) @@ -805,6 +886,104 @@ def _owner_response_intake_readiness(owner_response: dict[str, Any]) -> dict[str } +def _safe_credential_evidence_intake_readiness( + *, + owner_response: dict[str, Any], + required_target_count: int, +) -> dict[str, Any]: + summary = _dict(owner_response.get("summary")) + packet = _dict(owner_response.get("owner_response_request_packet")) + evidence_ref_rules = _strings(packet.get("evidence_ref_rules")) + redaction_examples = [ + _redaction_example_summary(row) + for row in _list(owner_response.get("owner_response_redaction_examples")) + ] + forbidden_payloads = _strings(packet.get("forbidden_payloads")) + checks = [ + *[ + _dict(row) + for row in _list(owner_response.get("owner_response_collection_checks")) + ], + *[_dict(row) for row in _list(owner_response.get("intake_preflight_checks"))], + *[_dict(row) for row in _list(owner_response.get("acceptance_checks"))], + ] + quarantine_lanes = sorted( + { + str(row.get("failure_lane")) + for row in checks + if str(row.get("failure_lane") or "").startswith("quarantine") + } + ) + accepted_evidence_count = 0 + required_redacted_evidence_ref_count = ( + required_target_count - accepted_evidence_count + ) + required_forbidden_payloads = { + "token_value", + "secret_value", + "private_clone_url_credential", + "repo_archive", + "git_object_pack", + } + intake_ready = ( + packet.get("execution_authorized") is False + and packet.get("not_approval") is True + and bool(evidence_ref_rules) + and bool(redaction_examples) + and _int(summary.get("owner_response_redaction_example_count")) + == len(redaction_examples) + and required_forbidden_payloads.issubset(set(forbidden_payloads)) + ) + + return { + "status": "ready_to_collect_redacted_evidence_refs_not_credentials" + if intake_ready + else "blocked_safe_credential_evidence_intake_contract_incomplete", + "intake_ready": intake_ready, + "required_target_count": required_target_count, + "accepted_evidence_count": accepted_evidence_count, + "required_redacted_evidence_ref_count": required_redacted_evidence_ref_count, + "allowed_evidence_ref_types": [ + "repo_path", + "snapshot_path", + "redacted_metadata_pointer", + ], + "evidence_ref_rule_count": len(evidence_ref_rules), + "evidence_ref_rules": evidence_ref_rules, + "redaction_example_count": len(redaction_examples), + "redaction_examples": redaction_examples, + "forbidden_payload_count": len(forbidden_payloads), + "forbidden_payloads": forbidden_payloads, + "quarantine_lane_count": len(quarantine_lanes), + "quarantine_lanes": quarantine_lanes, + "allowed_submission_modes": _strings(packet.get("allowed_submission_modes")), + "stored_raw_payload_allowed": False, + "secret_value_collection_allowed": False, + "private_clone_url_collection_allowed": False, + "credential_value_collection_allowed": False, + "target_owner_request_dispatch_authorized": False, + "action_buttons_allowed": False, + "execution_authorized": False, + "not_approval": True, + } + + +def _redaction_example_summary(value: Any) -> dict[str, Any]: + row = _dict(value) + return { + "example_id": str(row.get("example_id") or ""), + "display_order": _int(row.get("display_order")), + "example_status": str(row.get("example_status") or ""), + "category": str(row.get("category") or ""), + "safe_response_shape": _strings(row.get("safe_response_shape")), + "required_redactions": _strings(row.get("required_redactions")), + "forbidden_raw_values": _strings(row.get("forbidden_raw_values")), + "stored_raw_payload_allowed": False, + "execution_authorized": False, + "not_approval": row.get("not_approval") is True, + } + + def _owner_response_template_summary(value: Any) -> dict[str, Any]: row = _dict(value) return { @@ -888,6 +1067,6 @@ def _strings(value: Any) -> list[str]: def _int(value: Any) -> int: if isinstance(value, bool): return int(value) - if isinstance(value, (int, float)): + if isinstance(value, int | float): return int(value) return 0 diff --git a/apps/api/tests/test_github_target_private_backup_evidence_gate.py b/apps/api/tests/test_github_target_private_backup_evidence_gate.py index 7c0f81f05..fafc0dd69 100644 --- a/apps/api/tests/test_github_target_private_backup_evidence_gate.py +++ b/apps/api/tests/test_github_target_private_backup_evidence_gate.py @@ -55,6 +55,17 @@ def test_load_github_target_private_backup_evidence_gate_from_committed_snapshot assert snapshot["summary"]["github_missing_target_refs_sync_ready_count"] == 0 assert snapshot["summary"]["private_backup_verified_count"] == 4 assert snapshot["summary"]["private_visibility_verified_count"] == 4 + assert snapshot["summary"]["safe_credential_required_count"] == 9 + assert snapshot["summary"]["safe_credential_accepted_evidence_count"] == 0 + assert snapshot["summary"]["safe_credential_evidence_intake_ready"] is True + assert ( + snapshot["summary"]["safe_credential_required_redacted_evidence_ref_count"] == 9 + ) + assert snapshot["summary"]["safe_credential_evidence_ref_rule_count"] == 5 + assert snapshot["summary"]["safe_credential_redaction_example_count"] == 5 + assert snapshot["summary"]["safe_credential_forbidden_payload_count"] == 15 + assert snapshot["summary"]["safe_credential_quarantine_lane_count"] == 1 + assert snapshot["summary"]["safe_credential_raw_payload_storage_allowed"] is False assert snapshot["summary"]["owner_response_request_ready"] is True assert snapshot["summary"]["owner_response_required_response_item_count"] == 9 assert snapshot["summary"]["owner_response_requested_template_count"] == 9 @@ -97,6 +108,39 @@ def test_load_github_target_private_backup_evidence_gate_from_committed_snapshot assert "private_clone_url_credential" in intake["forbidden_payloads"] assert "read_only_markdown_response" in intake["allowed_submission_modes"] assert "create_github_repo" in intake["still_forbidden"] + safe_credential_intake = snapshot["safe_credential_evidence_intake_readiness"] + assert ( + safe_credential_intake["status"] + == "ready_to_collect_redacted_evidence_refs_not_credentials" + ) + assert safe_credential_intake["intake_ready"] is True + assert safe_credential_intake["required_target_count"] == 9 + assert safe_credential_intake["accepted_evidence_count"] == 0 + assert safe_credential_intake["required_redacted_evidence_ref_count"] == 9 + assert safe_credential_intake["evidence_ref_rule_count"] == 5 + assert safe_credential_intake["redaction_example_count"] == 5 + assert safe_credential_intake["forbidden_payload_count"] == 15 + assert safe_credential_intake["quarantine_lane_count"] == 1 + assert "quarantine_sensitive_payload" in safe_credential_intake["quarantine_lanes"] + assert safe_credential_intake["stored_raw_payload_allowed"] is False + assert safe_credential_intake["secret_value_collection_allowed"] is False + assert safe_credential_intake["private_clone_url_collection_allowed"] is False + assert safe_credential_intake["credential_value_collection_allowed"] is False + assert safe_credential_intake["execution_authorized"] is False + assert safe_credential_intake["not_approval"] is True + assert ( + "private_clone_url_credential" in safe_credential_intake["forbidden_payloads"] + ) + assert "repo_archive" in safe_credential_intake["forbidden_payloads"] + assert "git_object_pack" in safe_credential_intake["forbidden_payloads"] + assert ( + "redacted_metadata_pointer" + in safe_credential_intake["allowed_evidence_ref_types"] + ) + first_redaction_example = safe_credential_intake["redaction_examples"][0] + assert first_redaction_example["example_id"] == "redaction-github-target-doc-ref" + assert first_redaction_example["stored_raw_payload_allowed"] is False + assert first_redaction_example["execution_authorized"] is False targets = {target["github_repo"]: target for target in snapshot["targets"]} assert targets["owenhytsai/awoooi"]["visibility_evidence_status"] == ( @@ -112,6 +156,28 @@ def test_load_github_target_private_backup_evidence_gate_from_committed_snapshot targets["owenhytsai/awoooi"]["owner_response_submission_status"] == "waiting_owner_response" ) + assert ( + targets["owenhytsai/awoooi"]["safe_credential_evidence_submission_status"] + == "waiting_redacted_evidence_ref" + ) + assert ( + "redacted_metadata_pointer" + in targets["owenhytsai/awoooi"]["safe_credential_allowed_evidence_ref_types"] + ) + assert ( + targets["owenhytsai/awoooi"]["safe_credential_raw_payload_storage_allowed"] + is False + ) + assert ( + targets["owenhytsai/awoooi"][ + "safe_credential_private_clone_url_collection_allowed" + ] + is False + ) + assert ( + targets["owenhytsai/awoooi"]["safe_credential_secret_value_collection_allowed"] + is False + ) assert targets["owenhytsai/awoooi"]["owner_response_execution_authorized"] is False assert ( "canonical_source" @@ -152,6 +218,14 @@ def test_load_github_target_private_backup_evidence_gate_from_committed_snapshot targets["nexu-io/open-design"]["owner_response_submission_status"] == "not_required" ) + assert ( + targets["nexu-io/open-design"]["safe_credential_evidence_submission_status"] + == "not_required" + ) + assert ( + targets["nexu-io/open-design"]["safe_credential_allowed_evidence_ref_types"] + == [] + ) def test_github_target_private_backup_gate_rejects_runtime_authorization(tmp_path): @@ -182,6 +256,45 @@ def test_github_target_private_backup_gate_rejects_owner_response_execution_pack load_latest_github_target_private_backup_evidence_gate(tmp_path) +def test_github_target_private_backup_gate_rejects_raw_payload_redaction_example( + tmp_path, +): + _copy_security_snapshots(tmp_path) + owner_response_path = ( + tmp_path / "github-target-owner-decision-response.snapshot.json" + ) + owner_response = json.loads(owner_response_path.read_text(encoding="utf-8")) + owner_response["owner_response_redaction_examples"][0][ + "stored_raw_payload_allowed" + ] = True + owner_response_path.write_text(json.dumps(owner_response), encoding="utf-8") + + with pytest.raises(ValueError, match="redaction examples"): + load_latest_github_target_private_backup_evidence_gate(tmp_path) + + +def test_github_target_private_backup_gate_requires_safe_credential_blockers( + tmp_path, +): + _copy_security_snapshots(tmp_path) + owner_response_path = ( + tmp_path / "github-target-owner-decision-response.snapshot.json" + ) + owner_response = json.loads(owner_response_path.read_text(encoding="utf-8")) + forbidden_payloads = owner_response["owner_response_request_packet"][ + "forbidden_payloads" + ] + owner_response["owner_response_request_packet"]["forbidden_payloads"] = [ + payload + for payload in forbidden_payloads + if payload != "private_clone_url_credential" + ] + owner_response_path.write_text(json.dumps(owner_response), encoding="utf-8") + + with pytest.raises(ValueError, match="forbidden payloads"): + load_latest_github_target_private_backup_evidence_gate(tmp_path) + + def test_github_target_private_backup_gate_requires_decision_rollup_consistency( tmp_path, ): diff --git a/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py b/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py index 9fdeb536d..59e1213f1 100644 --- a/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py +++ b/apps/api/tests/test_github_target_private_backup_evidence_gate_api.py @@ -27,6 +27,13 @@ def test_github_target_private_backup_evidence_gate_endpoint_returns_read_only_g assert data["summary"]["github_missing_target_refs_sync_ready_count"] == 0 assert data["summary"]["private_backup_verified_count"] == 4 assert data["summary"]["private_visibility_verified_count"] == 4 + assert data["summary"]["safe_credential_evidence_intake_ready"] is True + assert data["summary"]["safe_credential_required_redacted_evidence_ref_count"] == 9 + assert data["summary"]["safe_credential_evidence_ref_rule_count"] == 5 + assert data["summary"]["safe_credential_redaction_example_count"] == 5 + assert data["summary"]["safe_credential_forbidden_payload_count"] == 15 + assert data["summary"]["safe_credential_quarantine_lane_count"] == 1 + assert data["summary"]["safe_credential_raw_payload_storage_allowed"] is False assert data["summary"]["owner_response_request_ready"] is True assert data["summary"]["owner_response_required_response_item_count"] == 9 assert data["summary"]["owner_response_requested_template_count"] == 9 @@ -57,5 +64,31 @@ def test_github_target_private_backup_evidence_gate_endpoint_returns_read_only_g assert intake["forbidden_payload_count"] == 15 assert intake["execution_authorized"] is False assert intake["not_approval"] is True + safe_credential_intake = data["safe_credential_evidence_intake_readiness"] + assert ( + safe_credential_intake["status"] + == "ready_to_collect_redacted_evidence_refs_not_credentials" + ) + assert safe_credential_intake["intake_ready"] is True + assert safe_credential_intake["required_target_count"] == 9 + assert safe_credential_intake["required_redacted_evidence_ref_count"] == 9 + assert safe_credential_intake["evidence_ref_rule_count"] == 5 + assert safe_credential_intake["redaction_example_count"] == 5 + assert safe_credential_intake["forbidden_payload_count"] == 15 + assert safe_credential_intake["quarantine_lane_count"] == 1 + assert safe_credential_intake["stored_raw_payload_allowed"] is False + assert safe_credential_intake["private_clone_url_collection_allowed"] is False + assert safe_credential_intake["secret_value_collection_allowed"] is False + assert safe_credential_intake["execution_authorized"] is False + assert safe_credential_intake["not_approval"] is True assert data["targets"][0]["owner_response_execution_authorized"] is False + assert ( + data["targets"][0]["safe_credential_evidence_submission_status"] + == "waiting_redacted_evidence_ref" + ) + assert data["targets"][0]["safe_credential_raw_payload_storage_allowed"] is False + assert ( + data["targets"][0]["safe_credential_private_clone_url_collection_allowed"] + is False + ) assert "192.168.0." not in response.text diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index b67d17b0f..ec8158d32 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -47644,3 +47644,39 @@ production browser smoke: **下一個 P0**: - 開新工作視窗處理 owner-provided redacted Wazuh manager registry export 收件與 reviewer validation passed;只有 evidence 完整、算術一致、6 alias scope parity、Dashboard API repair readback 與 owner / rollback owner 齊全時,才可推進 manager registry accepted,仍不得把 Dashboard 200 或前台可見當成全主機納管完成。 + +## 2026-06-27 — 21:47 GitHub safe credential evidence intake readiness 本地完成 + +**時間與來源**: +- 2026-06-27 21:47 Asia/Taipei。 +- 來源:feature branch `codex/github-safe-credential-intake-20260627`、`gitea/main=1a6f8f427`。 + +**完成內容**: +- `apps/api/src/services/github_target_private_backup_evidence_gate.py` 新增 `safe_credential_evidence_intake_readiness` readback,將 GitHub target owner response snapshot 內的 `evidence_ref_rules`、`owner_response_redaction_examples`、`forbidden_payloads`、quarantine lanes 與 submission modes 投影到 API。 +- summary 新增 safe credential intake counters:`safe_credential_evidence_intake_ready=true`、`safe_credential_required_redacted_evidence_ref_count=9`、`safe_credential_evidence_ref_rule_count=5`、`safe_credential_redaction_example_count=5`、`safe_credential_forbidden_payload_count=15`、`safe_credential_quarantine_lane_count=1`、`safe_credential_raw_payload_storage_allowed=false`。 +- per-target 新增安全收件狀態:approval-required targets 顯示 `waiting_redacted_evidence_ref`,external target 顯示 `not_required`;allowed ref types 只允許 `repo_path`、`snapshot_path`、`redacted_metadata_pointer`。 +- 一致性檢查新增 fail-closed guard:redaction example 不得允許 raw payload storage;forbidden payloads 必須包含 `token_value`、`secret_value`、`private_clone_url_credential`、`repo_archive`、`git_object_pack`。 + +**本地驗證結果**: +- `python3.11 -m ruff format apps/api/src/services/github_target_private_backup_evidence_gate.py apps/api/tests/test_github_target_private_backup_evidence_gate.py apps/api/tests/test_github_target_private_backup_evidence_gate_api.py`:通過,`3 files left unchanged`。 +- `python3.11 -m ruff check apps/api/src/services/github_target_private_backup_evidence_gate.py apps/api/tests/test_github_target_private_backup_evidence_gate.py apps/api/tests/test_github_target_private_backup_evidence_gate_api.py`:通過。 +- `python3 -m py_compile apps/api/src/services/github_target_private_backup_evidence_gate.py`:通過。 +- `DATABASE_URL=sqlite:///test.db PYTHONPATH=apps/api python3.11 -m pytest apps/api/tests/test_github_target_private_backup_evidence_gate.py apps/api/tests/test_github_target_private_backup_evidence_gate_api.py apps/api/tests/test_delivery_closure_workbench_api.py -q`:`9 passed`。 +- `git diff --check`:通過。 +- 本地 readback snippet:`safe_credential_evidence_intake_ready=True`、required redacted evidence refs `9`、evidence rules `5`、redaction examples `5`、forbidden payloads `15`、quarantine lanes `1`、raw payload storage allowed `False`、`private_backup_verified_count=4`、`execution_ready_count=0`、`blocked_target_count=9`。 + +**完成度與同步狀態**: +- 本段「GitHub safe credential evidence intake readiness source/API」本地:`0% -> 85%`。 +- 尚未 merge/push main、尚未 CD、尚未 production API readback;production 仍以前一版為準直到 Gitea CD 成功與 readback 完成。 + +**仍維持 0 / false**: +- `safe_credential_accepted_evidence_count=0`、`owner_response_received_count=0`、`owner_response_accepted_count=0`、`github_missing_target_create_private_repo_ready_count=0`、`github_missing_target_refs_sync_ready_count=0`、`execution_ready_count=0`、`blocked_target_count=9`。 +- `secret_value_collection_allowed=false`、`private_clone_url_collection_allowed=false`、`credential_value_collection_allowed=false`、`stored_raw_payload_allowed=false`、`repo_creation_authorized=false`、`visibility_change_authorized=false`、`refs_sync_authorized=false`、`workflow_trigger_authorized=false`。 + +**做過的命令類型**: +- 寫入:repo service / test / LOGBOOK。 +- 只讀:git fetch / merge check、本地 API readback snippet、本地 focused tests。 +- 未做:沒有 GitHub repo creation、visibility change、refs sync、workflow trigger、private clone URL collection、secret value collection;沒有 host / Docker / systemd / Nginx / firewall / K8s / DB / Wazuh runtime 寫操作;沒有 force push。 + +**下一個 P0**: +- commit feature,正常 push 到 Gitea;確認 CD idle/success 後 normal push `HEAD:main`,再做 production readback,目標為 `safe_credential_evidence_intake_ready=true`、required refs `9`、rules `5`、redaction examples `5`、forbidden payloads `15`、quarantine lanes `1`,同時 create/private ready `0`、refs sync ready `0`、execution ready `0` 維持不變。