diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index 92ecc0768..3d557bb33 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -340,6 +340,7 @@ from src.services.gitea_workflow_runner_health import ( from src.services.github_target_private_backup_evidence_gate import ( load_latest_github_target_private_backup_evidence_gate, preflight_github_target_owner_response_submission, + validate_github_target_safe_credential_evidence_refs, ) from src.services.host_runaway_aiops_loop_readiness import ( load_latest_host_runaway_aiops_loop_readiness, @@ -1027,6 +1028,44 @@ async def preflight_github_target_owner_response_intake( ) from exc +@router.post( + "/github-target-safe-credential-evidence-reviewer-validation/validate-redacted-refs", + response_model=dict[str, Any], + summary="驗證 GitHub target safe credential 脫敏 evidence refs", + description=( + "針對單次 owner-provided redacted safe credential evidence refs 進行 no-persist " + "reviewer validation,回傳 accepted / needs supplement / quarantined / rejected runtime " + "action 分流。此端點不保存 payload、不呼叫 GitHub live API、不建立 repo、不改 visibility、" + "不同步 refs、不觸發 workflow、不收 private clone URL credential 或任何 secret value,也不更新 " + "safe credential accepted evidence 總帳。" + ), +) +async def validate_github_target_safe_credential_evidence_review( + submission: dict[str, Any], +) -> dict[str, Any]: + """回傳單次 GitHub safe credential 脫敏 evidence refs 公開安全驗證結果。""" + try: + payload = await asyncio.to_thread( + validate_github_target_safe_credential_evidence_refs, + submission, + ) + 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: + logger.error( + "github_target_safe_credential_evidence_review_invalid", + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="GitHub target safe credential evidence review 無效", + ) from exc + + @router.get( "/agent-12-agent-war-room", response_model=dict[str, Any], 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 b50a57807..7d488f541 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 @@ -25,6 +25,38 @@ _CONNECTOR_READBACK_FILE = "github-target-connector-readback.snapshot.json" _MISSING_SOURCE_READINESS_FILE = "github-target-missing-source-readiness.snapshot.json" _PREFLIGHT_SCHEMA_VERSION = "github_target_owner_response_intake_preflight_v1" _PREFLIGHT_MODE = "validate_owner_response_only_no_persist_no_github_write" +_SAFE_CREDENTIAL_REVIEW_SCHEMA_VERSION = ( + "github_target_safe_credential_evidence_ref_validation_result_v1" +) +_SAFE_CREDENTIAL_REVIEW_MODE = ( + "validate_redacted_evidence_refs_only_no_persist_no_github_write" +) +_SAFE_CREDENTIAL_REVIEW_ENDPOINT = ( + "/api/v1/agents/" + "github-target-safe-credential-evidence-reviewer-validation/validate-redacted-refs" +) +_SAFE_CREDENTIAL_REVIEW_ACCEPT_DECISION = ( + "accept_redacted_evidence_refs_for_reviewer_validation" +) +_SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS = ( + "template_id", + "github_repo", + "reviewer_role_or_team", + "decision", + "decision_reason", + "evidence_ref_type", + "redacted_evidence_refs", +) +_SAFE_CREDENTIAL_REVIEW_OPTIONAL_FIELDS = { + "affected_scope", + "evidence_refs", + "followup_owner", + "redaction_status", + "response_id", + "source_system", + "submission_mode", + "validation_plan", +} _SUBMISSION_METADATA_FIELDS = { "github_repo", "response_id", @@ -44,10 +76,41 @@ _FORBIDDEN_KEY_FRAGMENTS = { "private_key", "repo_archive", "repo_creation_command", + "requested_action", + "requested_actions", + "runtime_action", + "runtime_execution", + "refs_sync", + "delete_refs", + "force_push", + "tag_rewrite", + "github_primary_switch", "secret_value", "session", "token_value", "visibility_change_command", + "workflow_trigger", + "write_or_admin_api_request", +} +_RUNTIME_ACTION_TERMS = { + "change_repo_visibility", + "create_github_repo", + "delete_refs", + "force_push", + "github_primary_switch", + "push_refs", + "refs_sync", + "refs_sync_or_delete_request", + "repo_creation_command", + "requested_action", + "requested_actions", + "runtime_action", + "runtime_execution", + "switch_github_primary", + "tag_rewrite", + "visibility_change_command", + "workflow_trigger", + "write_or_admin_api_request", } _SENSITIVE_VALUE_PATTERNS = ( ("github_token", re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b")), @@ -211,6 +274,181 @@ def preflight_github_target_owner_response_submission( } +def validate_github_target_safe_credential_evidence_refs( + submission: dict[str, Any], + security_dir: Path | None = None, +) -> dict[str, Any]: + """Validate redacted safe-credential evidence refs without persisting them.""" + gate = load_latest_github_target_private_backup_evidence_gate(security_dir) + safe_intake = _dict(gate.get("safe_credential_evidence_intake_readiness")) + owner_intake = _dict(gate.get("owner_response_intake_readiness")) + payload = _dict(submission) + allowed_modes = set(_strings(safe_intake.get("allowed_submission_modes"))) + allowed_ref_types = set(_strings(safe_intake.get("allowed_evidence_ref_types"))) + forbidden_payloads = ( + set(_strings(safe_intake.get("forbidden_payloads"))) + | set(_strings(owner_intake.get("still_forbidden"))) + | _RUNTIME_ACTION_TERMS + ) + target_by_template = { + str(target.get("owner_response_template_id")): _dict(target) + for target in _list(gate.get("targets")) + if target.get("owner_response_template_id") + } + + submission_mode = str(payload.get("submission_mode") or "") + candidate_items = [ + _dict(row) for row in _list(payload.get("evidence_ref_submissions")) + ] + if not candidate_items: + candidate_items = [_dict(row) for row in _list(payload.get("responses"))] + + mode_allowed = submission_mode in allowed_modes + global_scan_payload = { + key: value + for key, value in payload.items() + if key not in {"evidence_ref_submissions", "responses"} + } + global_hits = _forbidden_payload_hits( + global_scan_payload, + forbidden_payloads=forbidden_payloads, + ) + global_runtime_hits = _runtime_action_hits(global_hits) + item_results = [ + _validate_safe_credential_evidence_ref_item( + item=item, + submission_mode=submission_mode, + mode_allowed=mode_allowed, + target_by_template=target_by_template, + allowed_ref_types=allowed_ref_types, + forbidden_payloads=forbidden_payloads, + ) + for item in candidate_items + ] + passed_count = sum( + 1 for row in item_results if row["accepted_for_readonly_review"] is True + ) + runtime_rejected_count = sum( + 1 for row in item_results if row["runtime_action_rejected"] is True + ) + (1 if global_runtime_hits else 0) + quarantined_count = sum(1 for row in item_results if row["quarantined"] is True) + ( + 1 if global_hits and not global_runtime_hits else 0 + ) + blocked_count = len(item_results) - passed_count + + global_blockers: list[str] = [] + if not mode_allowed: + global_blockers.append("submission_mode_not_allowed") + if not candidate_items: + global_blockers.append("redacted_evidence_ref_items_missing") + if global_runtime_hits: + global_blockers.append("runtime_action_request_detected") + elif global_hits: + global_blockers.append("forbidden_payload_detected") + + if runtime_rejected_count: + status_value = "reject_runtime_action_request" + elif quarantined_count: + status_value = "quarantine_sensitive_payload" + elif global_blockers or blocked_count: + status_value = "request_redacted_evidence_ref_supplement" + else: + status_value = "accepted_for_readonly_safe_credential_evidence_review_only" + + accepted = ( + status_value == "accepted_for_readonly_safe_credential_evidence_review_only" + ) + return { + "schema_version": _SAFE_CREDENTIAL_REVIEW_SCHEMA_VERSION, + "contract_schema_version": gate["schema_version"], + "generated_at": gate.get("generated_at", ""), + "status": status_value, + "mode": _SAFE_CREDENTIAL_REVIEW_MODE, + "outcome_lane": status_value, + "accepted_for_readonly_review": accepted, + "reviewer_validation_passed": accepted, + "quarantined": status_value == "quarantine_sensitive_payload", + "runtime_action_rejected": status_value == "reject_runtime_action_request", + "submission_mode": submission_mode, + "submission_mode_allowed": mode_allowed, + "allowed_submission_modes": sorted(allowed_modes), + "global_blockers": global_blockers, + "global_forbidden_hits": global_hits, + "summary": { + "candidate_evidence_ref_item_count": len(candidate_items), + "reviewer_validation_passed_count": passed_count, + "reviewer_validation_blocked_count": blocked_count, + "reviewer_validation_quarantined_count": quarantined_count, + "runtime_action_rejected_count": runtime_rejected_count, + "forbidden_payload_hit_count": len(global_hits) + + sum(len(row["forbidden_hits"]) for row in item_results), + "unsafe_evidence_ref_hit_count": sum( + len(row["evidence_ref_hits"]) for row in item_results + ), + "missing_required_field_count": sum( + len(row["missing_required_fields"]) for row in item_results + ), + "safe_credential_evidence_submission_received_count": len(candidate_items), + "safe_credential_evidence_submission_accepted_count": passed_count, + "safe_credential_accepted_evidence_count": 0, + "private_backup_verified_count": gate["summary"][ + "private_backup_verified_count" + ], + "github_missing_target_create_private_repo_ready_count": gate["summary"][ + "github_missing_target_create_private_repo_ready_count" + ], + "github_missing_target_refs_sync_ready_count": gate["summary"][ + "github_missing_target_refs_sync_ready_count" + ], + "execution_ready_count": gate["summary"]["execution_ready_count"], + "blocked_target_count": gate["summary"]["blocked_target_count"], + "payload_persisted": False, + "safe_credential_accepted_updated": False, + "github_api_write_allowed": False, + "repo_creation_authorized": False, + "visibility_change_authorized": False, + "refs_sync_authorized": False, + "workflow_trigger_authorized": False, + "private_clone_url_collection_allowed": False, + "secret_value_collection_allowed": False, + "execution_authorized": False, + }, + "evidence_ref_submissions": item_results, + "boundary_markers": [ + "github_safe_credential_evidence_review_no_persist=true", + f"github_safe_credential_evidence_review_passed_count={passed_count}", + f"github_safe_credential_evidence_review_quarantined_count={quarantined_count}", + "github_safe_credential_accepted_evidence_count=0", + "github_safe_credential_accepted_updated=false", + "github_repo_creation_authorized=false", + "github_visibility_change_authorized=false", + "github_refs_sync_authorized=false", + "github_workflow_trigger_authorized=false", + "github_private_clone_url_collection_allowed=false", + "github_secret_value_collection_allowed=false", + "runtime_execution_authorized=false", + "not_authorization=true", + ], + "boundaries": { + "payload_persisted": False, + "github_api_write_allowed": False, + "repo_creation_authorized": False, + "visibility_change_authorized": False, + "refs_sync_authorized": False, + "workflow_trigger_authorized": False, + "private_clone_url_collection_allowed": False, + "secret_value_collection_allowed": False, + "raw_payload_storage_allowed": False, + "safe_credential_accepted_updated": False, + "runtime_execution_authorized": False, + "not_authorization": True, + }, + "next_gate": "private_backup_post_review_readback" + if accepted + else "redacted_evidence_ref_fix_and_resubmit", + } + + def build_github_target_private_backup_evidence_gate( *, decision: dict[str, Any], @@ -396,6 +634,14 @@ def build_github_target_private_backup_evidence_gate( "quarantine_lane_count" ], "safe_credential_raw_payload_storage_allowed": False, + "safe_credential_reviewer_validation_ready": safe_credential_evidence_intake_readiness[ + "reviewer_validation_ready" + ], + "safe_credential_reviewer_validation_required_field_count": safe_credential_evidence_intake_readiness[ + "reviewer_validation_required_field_count" + ], + "safe_credential_reviewer_validation_passed_count": 0, + "safe_credential_reviewer_validation_quarantined_count": 0, "owner_response_received_count": _int( owner_summary.get("received_response_count") ), @@ -559,6 +805,104 @@ def _preflight_owner_response_item( } +def _validate_safe_credential_evidence_ref_item( + *, + item: dict[str, Any], + submission_mode: str, + mode_allowed: bool, + target_by_template: dict[str, dict[str, Any]], + allowed_ref_types: set[str], + forbidden_payloads: set[str], +) -> dict[str, Any]: + template_id = str(item.get("template_id") or "") + target = target_by_template.get(template_id, {}) + github_repo = str(item.get("github_repo") or target.get("github_repo") or "") + evidence_ref_type = str(item.get("evidence_ref_type") or "") + decision = str(item.get("decision") or "") + response_fields = set(item) + supported_fields = ( + set(_SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS) + | _SAFE_CREDENTIAL_REVIEW_OPTIONAL_FIELDS + ) + unsupported_fields = sorted(response_fields - supported_fields) + missing_required_fields = sorted( + field + for field in _SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS + if not _has_response_value(item.get(field)) + ) + evidence_refs = sorted(set(_response_strings(item.get("redacted_evidence_refs")))) + blockers: list[str] = [] + if not mode_allowed: + blockers.append("submission_mode_not_allowed") + if not target: + blockers.append("unknown_or_unrequested_template_id") + if ( + target + and item.get("github_repo") + and str(item.get("github_repo")) != str(target.get("github_repo")) + ): + blockers.append("github_repo_mismatch_for_template") + if unsupported_fields: + blockers.append("unsupported_evidence_ref_fields") + if missing_required_fields: + blockers.append("required_fields_missing") + if evidence_ref_type not in allowed_ref_types: + blockers.append("evidence_ref_type_not_allowed") + if decision != _SAFE_CREDENTIAL_REVIEW_ACCEPT_DECISION: + blockers.append("decision_not_allowed_for_safe_credential_review") + if not evidence_refs: + blockers.append("redacted_evidence_refs_missing") + + forbidden_hits = _forbidden_payload_hits( + item, forbidden_payloads=forbidden_payloads + ) + runtime_hits = _runtime_action_hits(forbidden_hits) + evidence_ref_hits = _evidence_ref_hits(evidence_refs) + if runtime_hits: + blockers.append("runtime_action_request_detected") + elif forbidden_hits: + blockers.append("forbidden_payload_detected") + if evidence_ref_hits: + blockers.append("unsafe_evidence_ref_detected") + + accepted = not blockers + return { + "template_id": template_id, + "github_repo": github_repo, + "submission_mode": submission_mode, + "status": "accepted_for_readonly_safe_credential_evidence_review_only" + if accepted + else "blocked_safe_credential_evidence_ref_candidate", + "accepted_for_readonly_review": accepted, + "reviewer_validation_passed": accepted, + "quarantined": bool(forbidden_hits and not runtime_hits), + "runtime_action_rejected": bool(runtime_hits), + "decision": decision, + "allowed_decisions": [_SAFE_CREDENTIAL_REVIEW_ACCEPT_DECISION], + "evidence_ref_type": evidence_ref_type, + "allowed_evidence_ref_types": sorted(allowed_ref_types), + "required_field_count": len(_SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS), + "missing_required_fields": missing_required_fields, + "unsupported_fields": unsupported_fields, + "redacted_evidence_ref_count": len(evidence_refs), + "redacted_evidence_refs": evidence_refs if not evidence_ref_hits else [], + "evidence_ref_redacted_due_to_findings": bool(evidence_ref_hits), + "forbidden_hits": forbidden_hits, + "runtime_action_hits": runtime_hits, + "evidence_ref_hits": evidence_ref_hits, + "blockers": sorted(set(blockers)), + "safe_credential_evidence_received": True, + "safe_credential_evidence_accepted": False, + "safe_credential_accepted_updated": False, + "payload_persisted": False, + "execution_authorized": False, + "repo_creation_authorized": False, + "visibility_change_authorized": False, + "refs_sync_authorized": False, + "workflow_trigger_authorized": False, + } + + def _build_target( *, decision: dict[str, Any], @@ -1195,6 +1539,19 @@ def _safe_credential_evidence_intake_readiness( "quarantine_lane_count": len(quarantine_lanes), "quarantine_lanes": quarantine_lanes, "allowed_submission_modes": _strings(packet.get("allowed_submission_modes")), + "reviewer_validation_endpoint": _SAFE_CREDENTIAL_REVIEW_ENDPOINT, + "reviewer_validation_mode": _SAFE_CREDENTIAL_REVIEW_MODE, + "reviewer_validation_ready": intake_ready, + "reviewer_validation_required_field_count": len( + _SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS + ), + "reviewer_validation_required_fields": list( + _SAFE_CREDENTIAL_REVIEW_REQUIRED_FIELDS + ), + "reviewer_validation_allowed_decisions": [ + _SAFE_CREDENTIAL_REVIEW_ACCEPT_DECISION + ], + "reviewer_validation_no_persist": True, "stored_raw_payload_allowed": False, "secret_value_collection_allowed": False, "private_clone_url_collection_allowed": False, @@ -1317,9 +1674,8 @@ def _forbidden_payload_hits( for key, child in value.items(): key_text = str(key) key_lower = key_text.lower() - if ( - key_lower in forbidden_payloads - or any(fragment in key_lower for fragment in _FORBIDDEN_KEY_FRAGMENTS) + if key_lower in forbidden_payloads or any( + fragment in key_lower for fragment in _FORBIDDEN_KEY_FRAGMENTS ): hits.append( { @@ -1382,7 +1738,7 @@ def _evidence_ref_hits(evidence_refs: list[str]) -> list[dict[str, str]]: { "path": f"evidence_refs[{index}]", "kind": "unsupported_evidence_ref_scheme", - "match": evidence_ref, + "match": "unsupported_evidence_ref_scheme", } ) for label, pattern in _SENSITIVE_VALUE_PATTERNS: @@ -1397,6 +1753,16 @@ def _evidence_ref_hits(evidence_refs: list[str]) -> list[dict[str, str]]: return hits +def _runtime_action_hits(forbidden_hits: list[dict[str, str]]) -> list[dict[str, str]]: + hits: list[dict[str, str]] = [] + for hit in forbidden_hits: + match = str(hit.get("match") or "").lower() + path = str(hit.get("path") or "").lower() + if any(term in match or term in path for term in _RUNTIME_ACTION_TERMS): + hits.append(hit) + return hits + + def _dict(value: Any) -> dict[str, Any]: return value if isinstance(value, dict) else {} 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 34d969189..a9e1981d1 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 @@ -9,6 +9,7 @@ import pytest from src.services.github_target_private_backup_evidence_gate import ( load_latest_github_target_private_backup_evidence_gate, preflight_github_target_owner_response_submission, + validate_github_target_safe_credential_evidence_refs, ) from src.services.snapshot_paths import default_security_dir @@ -67,6 +68,16 @@ def test_load_github_target_private_backup_evidence_gate_from_committed_snapshot 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"]["safe_credential_reviewer_validation_ready"] is True + assert ( + snapshot["summary"]["safe_credential_reviewer_validation_required_field_count"] + == 7 + ) + assert snapshot["summary"]["safe_credential_reviewer_validation_passed_count"] == 0 + assert ( + snapshot["summary"]["safe_credential_reviewer_validation_quarantined_count"] + == 0 + ) 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 @@ -134,6 +145,25 @@ def test_load_github_target_private_backup_evidence_gate_from_committed_snapshot ) assert "repo_archive" in safe_credential_intake["forbidden_payloads"] assert "git_object_pack" in safe_credential_intake["forbidden_payloads"] + assert ( + safe_credential_intake["reviewer_validation_endpoint"] + == "/api/v1/agents/github-target-safe-credential-evidence-reviewer-validation/validate-redacted-refs" + ) + assert ( + safe_credential_intake["reviewer_validation_mode"] + == "validate_redacted_evidence_refs_only_no_persist_no_github_write" + ) + assert safe_credential_intake["reviewer_validation_ready"] is True + assert safe_credential_intake["reviewer_validation_required_field_count"] == 7 + assert ( + "redacted_evidence_refs" + in safe_credential_intake["reviewer_validation_required_fields"] + ) + assert ( + "accept_redacted_evidence_refs_for_reviewer_validation" + in safe_credential_intake["reviewer_validation_allowed_decisions"] + ) + assert safe_credential_intake["reviewer_validation_no_persist"] is True assert ( "redacted_metadata_pointer" in safe_credential_intake["allowed_evidence_ref_types"] @@ -332,7 +362,9 @@ def test_github_target_owner_response_preflight_accepts_redacted_evidence_refs() == "github_target_owner_response_intake_preflight_v1" ) assert preflight["status"] == "ready_for_read_only_owner_response_intake" - assert preflight["mode"] == "validate_owner_response_only_no_persist_no_github_write" + assert ( + preflight["mode"] == "validate_owner_response_only_no_persist_no_github_write" + ) assert preflight["summary"]["candidate_response_item_count"] == 1 assert preflight["summary"]["preflight_passed_response_item_count"] == 1 assert preflight["summary"]["preflight_blocked_response_item_count"] == 0 @@ -344,8 +376,13 @@ def test_github_target_owner_response_preflight_accepts_redacted_evidence_refs() assert preflight["summary"]["refs_sync_authorized"] is False assert preflight["operation_boundaries"]["persist_submission_allowed"] is False assert preflight["operation_boundaries"]["github_api_write_allowed"] is False - assert preflight["operation_boundaries"]["private_clone_url_collection_allowed"] is False - assert preflight["authorization_flags"]["owner_response_execution_authorized"] is False + assert ( + preflight["operation_boundaries"]["private_clone_url_collection_allowed"] + is False + ) + assert ( + preflight["authorization_flags"]["owner_response_execution_authorized"] is False + ) assert preflight["responses"][0]["accepted_for_read_only_intake"] is True assert preflight["responses"][0]["owner_response_received"] is False assert preflight["responses"][0]["owner_response_accepted"] is False @@ -353,12 +390,12 @@ def test_github_target_owner_response_preflight_accepts_redacted_evidence_refs() def test_github_target_owner_response_preflight_blocks_credentials_and_commands(): submission = _valid_owner_response_submission() - submission["responses"][0]["private_clone_url_credential"] = ( - "https://owner:ghp_1234567890abcdefghijklmnopqrstu@github.com/owenhytsai/awoooi.git" - ) - submission["responses"][0]["repo_creation_command"] = ( - "gh repo create owenhytsai/awoooi --private" - ) + submission["responses"][0][ + "private_clone_url_credential" + ] = "https://owner:ghp_1234567890abcdefghijklmnopqrstu@github.com/owenhytsai/awoooi.git" + submission["responses"][0][ + "repo_creation_command" + ] = "gh repo create owenhytsai/awoooi --private" preflight = preflight_github_target_owner_response_submission(submission) @@ -378,6 +415,92 @@ def test_github_target_owner_response_preflight_blocks_credentials_and_commands( assert response["repo_creation_authorized"] is False +def test_github_target_safe_credential_evidence_review_accepts_redacted_refs(): + payload = validate_github_target_safe_credential_evidence_refs( + _valid_safe_credential_evidence_ref_submission() + ) + + assert ( + payload["schema_version"] + == "github_target_safe_credential_evidence_ref_validation_result_v1" + ) + assert ( + payload["status"] + == "accepted_for_readonly_safe_credential_evidence_review_only" + ) + assert ( + payload["mode"] + == "validate_redacted_evidence_refs_only_no_persist_no_github_write" + ) + assert payload["accepted_for_readonly_review"] is True + assert payload["reviewer_validation_passed"] is True + assert payload["summary"]["candidate_evidence_ref_item_count"] == 1 + assert payload["summary"]["reviewer_validation_passed_count"] == 1 + assert payload["summary"]["safe_credential_evidence_submission_accepted_count"] == 1 + assert payload["summary"]["safe_credential_accepted_evidence_count"] == 0 + assert payload["summary"]["private_backup_verified_count"] == 4 + assert payload["summary"]["execution_ready_count"] == 0 + assert payload["summary"]["blocked_target_count"] == 9 + assert payload["boundaries"]["payload_persisted"] is False + assert payload["boundaries"]["safe_credential_accepted_updated"] is False + assert payload["boundaries"]["runtime_execution_authorized"] is False + assert payload["boundaries"]["private_clone_url_collection_allowed"] is False + assert ( + payload["evidence_ref_submissions"][0]["accepted_for_readonly_review"] is True + ) + assert ( + payload["evidence_ref_submissions"][0]["safe_credential_evidence_accepted"] + is False + ) + + +def test_github_target_safe_credential_evidence_review_quarantines_sensitive_refs(): + submission = _valid_safe_credential_evidence_ref_submission() + secret_url = ( + "https://owner:ghp_1234567890abcdefghijklmnopqrstu@github.com/" + "owenhytsai/awoooi.git" + ) + submission["evidence_ref_submissions"][0]["redacted_evidence_refs"] = [secret_url] + submission["evidence_ref_submissions"][0]["repo_archive"] = "repo_archive" + + payload = validate_github_target_safe_credential_evidence_refs(submission) + + assert payload["status"] == "quarantine_sensitive_payload" + assert payload["quarantined"] is True + assert payload["summary"]["reviewer_validation_quarantined_count"] == 1 + assert payload["summary"]["safe_credential_accepted_evidence_count"] == 0 + assert ( + payload["evidence_ref_submissions"][0]["accepted_for_readonly_review"] is False + ) + assert ( + "forbidden_payload_detected" + in payload["evidence_ref_submissions"][0]["blockers"] + ) + assert ( + "unsafe_evidence_ref_detected" + in payload["evidence_ref_submissions"][0]["blockers"] + ) + assert secret_url not in str(payload) + assert "ghp_1234567890abcdefghijklmnopqrstu" not in str(payload) + + +def test_github_target_safe_credential_evidence_review_rejects_runtime_requests(): + submission = _valid_safe_credential_evidence_ref_submission() + submission["evidence_ref_submissions"][0]["requested_actions"] = ["refs_sync"] + + payload = validate_github_target_safe_credential_evidence_refs(submission) + + assert payload["status"] == "reject_runtime_action_request" + assert payload["runtime_action_rejected"] is True + assert payload["summary"]["runtime_action_rejected_count"] == 1 + assert payload["summary"]["safe_credential_accepted_evidence_count"] == 0 + assert payload["boundaries"]["refs_sync_authorized"] is False + assert ( + "runtime_action_request_detected" + in payload["evidence_ref_submissions"][0]["blockers"] + ) + + def _copy_security_snapshots(tmp_path: Path) -> None: source_dir = default_security_dir(Path(__file__)) for filename in ( @@ -420,3 +543,26 @@ def _valid_owner_response_submission() -> dict[str, object]: } ], } + + +def _valid_safe_credential_evidence_ref_submission() -> dict[str, object]: + return { + "submission_mode": "redacted_metadata_pointer", + "evidence_ref_submissions": [ + { + "template_id": "target-awoooi-refs-blocked", + "github_repo": "owenhytsai/awoooi", + "reviewer_role_or_team": "platform-owner", + "decision": "accept_redacted_evidence_refs_for_reviewer_validation", + "decision_reason": "Refs are redacted metadata pointers only.", + "evidence_ref_type": "redacted_metadata_pointer", + "redacted_evidence_refs": [ + "docs/security/GITEA-GITHUB-MIGRATION-SNAPSHOT.md", + "owner-metadata:github-target-awoooi-redacted-ref", + ], + "affected_scope": "awoooi github private backup target", + "followup_owner": "platform-owner", + "validation_plan": "review redacted refs only; no GitHub write", + } + ], + } 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 7a6493f0a..198d99ad1 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 @@ -34,6 +34,11 @@ def test_github_target_private_backup_evidence_gate_endpoint_returns_read_only_g 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"]["safe_credential_reviewer_validation_ready"] is True + assert ( + data["summary"]["safe_credential_reviewer_validation_required_field_count"] == 7 + ) + assert data["summary"]["safe_credential_reviewer_validation_passed_count"] == 0 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 @@ -81,6 +86,11 @@ def test_github_target_private_backup_evidence_gate_endpoint_returns_read_only_g 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 safe_credential_intake["reviewer_validation_ready"] is True + assert ( + safe_credential_intake["reviewer_validation_endpoint"] + == "/api/v1/agents/github-target-safe-credential-evidence-reviewer-validation/validate-redacted-refs" + ) assert data["targets"][0]["owner_response_execution_authorized"] is False assert ( data["targets"][0]["safe_credential_evidence_submission_status"] @@ -149,3 +159,57 @@ def test_github_target_owner_response_intake_preflight_endpoint_blocks_secrets() assert data["responses"][0]["accepted_for_read_only_intake"] is False assert "forbidden_payload_detected" in data["responses"][0]["blockers"] assert "192.168.0." not in response.text + + +def test_github_target_safe_credential_evidence_review_api_does_not_persist(): + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.post( + "/api/v1/agents/github-target-safe-credential-evidence-reviewer-validation/validate-redacted-refs", + json={ + "submission_mode": "redacted_metadata_pointer", + "evidence_ref_submissions": [ + { + "template_id": "target-awoooi-refs-blocked", + "github_repo": "owenhytsai/awoooi", + "reviewer_role_or_team": "platform-owner", + "decision": "accept_redacted_evidence_refs_for_reviewer_validation", + "decision_reason": "Refs are redacted metadata pointers only.", + "evidence_ref_type": "redacted_metadata_pointer", + "redacted_evidence_refs": [ + "docs/security/GITEA-GITHUB-MIGRATION-SNAPSHOT.md", + "owner-metadata:github-target-awoooi-redacted-ref", + ], + "affected_scope": "awoooi github private backup target", + "followup_owner": "platform-owner", + "validation_plan": "review redacted refs only; no GitHub write", + } + ], + }, + ) + + assert response.status_code == 200 + result = response.json() + assert ( + result["status"] == "accepted_for_readonly_safe_credential_evidence_review_only" + ) + assert result["summary"]["reviewer_validation_passed_count"] == 1 + assert result["summary"]["safe_credential_accepted_evidence_count"] == 0 + assert result["boundaries"]["payload_persisted"] is False + assert result["boundaries"]["github_api_write_allowed"] is False + assert result["boundaries"]["private_clone_url_collection_allowed"] is False + + readback = client.get( + "/api/v1/agents/github-target-private-backup-evidence-gate" + ).json() + assert readback["summary"]["safe_credential_accepted_evidence_count"] == 0 + assert readback["summary"]["safe_credential_reviewer_validation_passed_count"] == 0 + assert ( + readback["summary"]["github_missing_target_create_private_repo_ready_count"] + == 0 + ) + assert readback["summary"]["github_missing_target_refs_sync_ready_count"] == 0 + assert readback["summary"]["execution_ready_count"] == 0 + assert "192.168.0." not in response.text diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 94c4fb6be..512351396 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -47939,3 +47939,40 @@ production browser smoke: - `P0-01` GitHub private backup evidence acceptance:逐 target 收 owner-provided redacted evidence refs,讓 `safe_credential_accepted_evidence_count` 從 `0/9` 往上推;仍不得收 private clone URL、secret value、repo archive 或 git object pack。 - `P0-02` missing target canonical source owner decision:釐清 `ewoooc`、`bitan-pharmacy`、`tsenyang-website`、`VibeWork`、`agent-bounty-protocol` 的 canonical source before repo creation。 - `P0-03` refs sync readiness:canonical source 與 owner decision 未成立前,`refs_sync_ready_count` 必須維持 `0`。 + +## 2026-06-27 — 22:56 GitHub safe credential evidence refs reviewer validation 本地完成 + +**時間與來源**: +- 2026-06-27 22:56 Asia/Taipei。 +- 來源:feature branch `codex/github-redacted-evidence-validator-20260627`、`gitea/main=0040a595a`。 + +**完成內容**: +- `apps/api/src/services/github_target_private_backup_evidence_gate.py` 新增 `validate_github_target_safe_credential_evidence_refs()`,針對 owner-provided redacted safe credential evidence refs 做 no-persist reviewer validation。 +- `GET /api/v1/agents/github-target-private-backup-evidence-gate` 新增 reviewer validation readback 欄位:`safe_credential_reviewer_validation_ready=true`、required fields `7`、committed gate `safe_credential_reviewer_validation_passed_count=0`、`safe_credential_reviewer_validation_quarantined_count=0`。 +- 新增 `POST /api/v1/agents/github-target-safe-credential-evidence-reviewer-validation/validate-redacted-refs`,只回傳 accepted / supplement / quarantine / runtime request rejected 分流;不 persist payload、不呼叫 GitHub live API、不建立 repo、不改 visibility、不同步 refs、不觸發 workflow、不收 private clone URL 或 secret value、不更新 accepted 總帳。 +- unsafe evidence ref hit 不再 echo 原始不安全 ref,避免 private clone URL / token 被錯誤回顯。 + +**本地驗證結果**: +- `python3.11 -m ruff format ...`:通過,`4 files reformatted`。 +- `python3.11 -m ruff check apps/api/src/services/github_target_private_backup_evidence_gate.py apps/api/src/api/v1/agents.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 apps/api/src/api/v1/agents.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`:`16 passed`。 +- `git diff --check`:通過。 +- 本地 readback snippet:gate reviewer validation ready `True`、required fields `7`、gate reviewer validation passed `0`、`safe_credential_accepted_evidence_count=0`、create/private ready `0`、refs sync ready `0`;單次 validator request status `accepted_for_readonly_safe_credential_evidence_review_only`、request passed `1`、request `safe_credential_accepted_evidence_count=0`、`payload_persisted=False`、`runtime_execution_authorized=False`。 + +**完成度與同步狀態**: +- 本段「GitHub safe credential evidence refs reviewer validation source/API」本地:`0% -> 85%`。 +- 尚未 push feature、尚未 merge/push main、尚未 CD、尚未 production API readback;production 仍以前一版為準直到 Gitea CD 成功與 readback 完成。 + +**仍維持 0 / false**: +- committed gate:`safe_credential_accepted_evidence_count=0`、`safe_credential_reviewer_validation_passed_count=0`、`safe_credential_reviewer_validation_quarantined_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`、`private_backup_verified_count=4`。 +- `repo_creation_authorized=false`、`visibility_change_authorized=false`、`refs_sync_authorized=false`、`workflow_trigger_authorized=false`、`secret_value_collection_allowed=false`、`private_clone_url_collection_allowed=false`、`stored_raw_payload_allowed=false`。 + +**做過的命令類型**: +- 寫入:repo service / API route / test / LOGBOOK。 +- 只讀:git fetch、治理入口與 HARD_RULES / LOGBOOK 精準段落、本地 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;確認 main CD idle/success 後 normal push `HEAD:main`,再做 production readback,目標為 `safe_credential_reviewer_validation_ready=true`、required fields `7`、committed gate reviewer validation passed `0`、`safe_credential_accepted_evidence_count=0`,同時 create/private ready `0`、refs sync ready `0`、execution ready `0` 維持不變。