feat(github): validate redacted evidence refs

This commit is contained in:
Your Name
2026-06-27 22:59:31 +08:00
parent 0040a595a4
commit 82e9d780bb
5 changed files with 665 additions and 13 deletions

View File

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

View File

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