361 lines
15 KiB
Python
361 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""驗證 IwoooS / S4.9 owner gate 維持只讀收件邊界。
|
||
|
||
本 guard 只讀取 repo 內的 S4.9 / source-control owner response snapshot
|
||
與 Markdown 規範,不送 request、不收 response、不呼叫 Gitea / GitHub /
|
||
AwoooP、不修改 repo / refs / workflow / secret / runner,也不開 runtime gate。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
REQUIRED_DOCS = [
|
||
"docs/security/S4-9-OWNER-RESPONSE-GATE-CURRENT-GAP-AUDIT.md",
|
||
"docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md",
|
||
"docs/security/S4-9-OWNER-RESPONSE-INTAKE-FORM.md",
|
||
"docs/security/S4-9-REVIEWER-VALIDATION-CHECKLIST.md",
|
||
"docs/security/S4-9-SECURITY-ACCEPTANCE-RECORD-TEMPLATE.md",
|
||
"docs/security/GITEA-INVENTORY-OWNER-ATTESTATION-RESPONSE.md",
|
||
"docs/security/SOURCE-CONTROL-OWNER-RESPONSE-VALIDATION-ROLLUP.md",
|
||
"docs/security/GITHUB-TARGET-OWNER-DECISION-RESPONSE.md",
|
||
"docs/security/SOURCE-CONTROL-REF-TRUTH-OWNER-RESPONSE.md",
|
||
"docs/security/SOURCE-CONTROL-WORKFLOW-SECRET-NAME-OWNER-RESPONSE.md",
|
||
]
|
||
|
||
CANONICAL_FIELDS = [
|
||
"owner_role_or_team",
|
||
"decision",
|
||
"decision_reason",
|
||
"affected_scope",
|
||
"redacted_evidence_refs",
|
||
"followup_owner",
|
||
]
|
||
|
||
S4_9_TEMPLATES = [
|
||
"response-public-only-vs-local-gitea-gap",
|
||
"response-org-user-endpoint-identity",
|
||
"response-internal-110-adjacent-scope",
|
||
"response-repo-owner-canonical-scope",
|
||
"response-legacy-or-inaccessible-disposition",
|
||
]
|
||
|
||
OWNER_PACKET_SPECS = [
|
||
{
|
||
"label": "s4.9 gitea owner attestation response",
|
||
"path": "docs/security/gitea-inventory-owner-attestation-response.snapshot.json",
|
||
"schema": "gitea_inventory_owner_attestation_response_v1",
|
||
"status": "draft_waiting_owner_response",
|
||
"template_count": 5,
|
||
"expected_template_ids": S4_9_TEMPLATES,
|
||
"summary_counts": {
|
||
"owner_response_request_packet_count": 1,
|
||
"response_template_count": 5,
|
||
"owner_response_template_status_count": 5,
|
||
"intake_preflight_check_count": 6,
|
||
"intake_outcome_lane_count": 5,
|
||
"acceptance_check_count": 8,
|
||
"rejection_rule_count": 10,
|
||
"received_response_count": 0,
|
||
"accepted_response_count": 0,
|
||
"rejected_response_count": 0,
|
||
"owner_response_metadata_intake_required_count": 6,
|
||
"owner_response_metadata_intake_received_count": 0,
|
||
"owner_response_metadata_intake_accepted_count": 0,
|
||
"owner_response_metadata_intake_runtime_gate_count": 0,
|
||
"owner_response_intake_handoff_queue_count": 5,
|
||
"owner_response_intake_handoff_queue_received_count": 0,
|
||
"owner_response_intake_handoff_queue_accepted_count": 0,
|
||
"owner_response_intake_handoff_queue_runtime_gate_count": 0,
|
||
},
|
||
"false_flags": [
|
||
"runtime_execution_authorized",
|
||
"action_buttons_allowed",
|
||
"token_value_collection_allowed",
|
||
"raw_secret_allowed",
|
||
"repo_write_allowed",
|
||
"refs_sync_allowed",
|
||
"github_primary_switch_authorized",
|
||
"owner_response_metadata_intake_raw_payload_allowed",
|
||
"owner_response_metadata_intake_secret_plaintext_allowed",
|
||
"owner_response_metadata_intake_action_buttons_allowed",
|
||
"owner_response_intake_handoff_queue_raw_payload_allowed",
|
||
"owner_response_intake_handoff_queue_action_buttons_allowed",
|
||
],
|
||
},
|
||
{
|
||
"label": "s4.10 github target owner decision response",
|
||
"path": "docs/security/github-target-owner-decision-response.snapshot.json",
|
||
"schema": "github_target_owner_decision_response_v1",
|
||
"status": "draft_waiting_owner_response",
|
||
"template_count": 9,
|
||
"summary_counts": {
|
||
"owner_response_request_packet_count": 1,
|
||
"response_template_count": 9,
|
||
"owner_response_template_status_count": 9,
|
||
"intake_preflight_check_count": 6,
|
||
"acceptance_check_count": 8,
|
||
"rejection_rule_count": 10,
|
||
"received_response_count": 0,
|
||
"accepted_response_count": 0,
|
||
"rejected_response_count": 0,
|
||
},
|
||
"false_flags": [
|
||
"runtime_execution_authorized",
|
||
"action_buttons_allowed",
|
||
"repo_creation_authorized",
|
||
"visibility_change_authorized",
|
||
"refs_sync_authorized",
|
||
"github_primary_switch_authorized",
|
||
"secret_value_collection_allowed",
|
||
"target_owner_request_dispatch_authorized",
|
||
],
|
||
},
|
||
{
|
||
"label": "s4.11 ref truth owner response",
|
||
"path": "docs/security/source-control-ref-truth-owner-response.snapshot.json",
|
||
"schema": "source_control_ref_truth_owner_response_v1",
|
||
"status": "draft_waiting_owner_response",
|
||
"template_count": 5,
|
||
"summary_counts": {
|
||
"owner_response_request_packet_count": 1,
|
||
"response_template_count": 5,
|
||
"owner_response_template_status_count": 5,
|
||
"intake_preflight_check_count": 6,
|
||
"acceptance_check_count": 8,
|
||
"rejection_rule_count": 10,
|
||
"received_response_count": 0,
|
||
"accepted_response_count": 0,
|
||
"rejected_response_count": 0,
|
||
},
|
||
"false_flags": [
|
||
"runtime_execution_authorized",
|
||
"action_buttons_allowed",
|
||
"refs_sync_authorized",
|
||
"refs_delete_authorized",
|
||
"force_push_authorized",
|
||
"github_primary_switch_authorized",
|
||
"secret_value_collection_allowed",
|
||
],
|
||
},
|
||
{
|
||
"label": "s4.12 workflow secret owner response",
|
||
"path": "docs/security/source-control-workflow-secret-name-owner-response.snapshot.json",
|
||
"schema": "source_control_workflow_secret_name_owner_response_v1",
|
||
"status": "draft_waiting_owner_response",
|
||
"template_count": 5,
|
||
"summary_counts": {
|
||
"owner_response_request_packet_count": 1,
|
||
"response_template_count": 5,
|
||
"owner_response_template_status_count": 5,
|
||
"intake_preflight_check_count": 6,
|
||
"acceptance_check_count": 8,
|
||
"rejection_rule_count": 10,
|
||
"received_response_count": 0,
|
||
"accepted_response_count": 0,
|
||
"rejected_response_count": 0,
|
||
},
|
||
"false_flags": [
|
||
"runtime_execution_authorized",
|
||
"action_buttons_allowed",
|
||
"workflow_modification_authorized",
|
||
"repo_secret_change_authorized",
|
||
"runner_change_authorized",
|
||
"webhook_modification_authorized",
|
||
"branch_protection_change_authorized",
|
||
"deploy_key_change_authorized",
|
||
"github_hosted_runner_enable_authorized",
|
||
"secret_value_collection_allowed",
|
||
"secret_value_or_hash_collection_allowed",
|
||
"workflow_secret_owner_request_dispatch_authorized",
|
||
"write_token_allowed",
|
||
],
|
||
},
|
||
]
|
||
|
||
|
||
def load_json(path: Path) -> dict[str, Any]:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def fail(message: str) -> None:
|
||
raise SystemExit(f"BLOCKED {message}")
|
||
|
||
|
||
def assert_equal(label: str, actual: Any, expected: Any) -> None:
|
||
if actual != expected:
|
||
fail(f"{label}: expected {expected!r}, got {actual!r}")
|
||
|
||
|
||
def assert_contains(label: str, values: list[Any], expected: Any) -> None:
|
||
if expected not in values:
|
||
fail(f"{label}: missing {expected!r}")
|
||
|
||
|
||
def assert_text_contains(label: str, text: str, expected: str) -> None:
|
||
if expected not in text:
|
||
fail(f"{label}: missing {expected!r}")
|
||
|
||
|
||
def assert_path_exists(root: Path, relative_path: str) -> None:
|
||
if not (root / relative_path).exists():
|
||
fail(f"path missing: {relative_path}")
|
||
|
||
|
||
def summary_value(data: dict[str, Any], key: str) -> Any:
|
||
summary = data.get("summary", {})
|
||
if key in summary:
|
||
return summary[key]
|
||
return data.get(key)
|
||
|
||
|
||
def assert_false_summary_flag(label: str, data: dict[str, Any], key: str) -> None:
|
||
value = summary_value(data, key)
|
||
assert_equal(f"{label}.{key}", value, False)
|
||
|
||
|
||
def validate_docs(root: Path) -> None:
|
||
for relative_path in REQUIRED_DOCS:
|
||
assert_path_exists(root, relative_path)
|
||
|
||
canonical_text = (root / "docs/security/S4-9-CANONICAL-OWNER-RESPONSE-ENVELOPE.md").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
intake_text = (root / "docs/security/S4-9-OWNER-RESPONSE-INTAKE-FORM.md").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
gap_text = (root / "docs/security/S4-9-OWNER-RESPONSE-GATE-CURRENT-GAP-AUDIT.md").read_text(
|
||
encoding="utf-8"
|
||
)
|
||
for field in CANONICAL_FIELDS:
|
||
assert_text_contains("canonical_envelope.fields", canonical_text, field)
|
||
assert_text_contains("intake_form.fields", intake_text, field)
|
||
for template_id in S4_9_TEMPLATES:
|
||
assert_text_contains("canonical_envelope.templates", canonical_text, template_id)
|
||
assert_text_contains("intake_form.templates", intake_text, template_id)
|
||
for marker in [
|
||
"request_sent=false",
|
||
"received_response_count=0",
|
||
"accepted_response_count=0",
|
||
"runtime_execution_authorized=false",
|
||
"action_buttons_allowed=false",
|
||
]:
|
||
assert_text_contains("canonical_envelope.zero_boundary", canonical_text, marker)
|
||
assert_text_contains("intake_form.zero_boundary", intake_text, marker)
|
||
assert_text_contains("gap_audit.owner_gate_zero", gap_text, "S4.9 owner response gate 仍是 `0%`")
|
||
|
||
|
||
def validate_gap_audit(root: Path) -> None:
|
||
gap = load_json(root / "docs/security/s4-9-owner-response-gap-audit.snapshot.json")
|
||
summary = gap["summary"]
|
||
assert_equal("gap.schema_version", gap["schema_version"], "s4_9_owner_response_gap_audit_v1")
|
||
assert_equal("gap.status", gap["status"], "gap_audit_ready_owner_gate_zero")
|
||
assert_equal("gap.summary.current_requirement_gap_count", summary["current_requirement_gap_count"], 8)
|
||
assert_equal("gap.summary.new_rule_count", summary["new_rule_count"], 7)
|
||
assert_equal("gap.summary.rule_adjustment_count", summary["rule_adjustment_count"], 7)
|
||
assert_equal("gap.summary.priority_work_item_count", summary["priority_work_item_count"], 9)
|
||
for key in [
|
||
"request_sent_count",
|
||
"owner_response_received_count",
|
||
"owner_response_accepted_count",
|
||
"owner_response_rejected_count",
|
||
"runtime_gate_count",
|
||
]:
|
||
assert_equal(f"gap.summary.{key}", summary[key], 0)
|
||
assert_equal("gap.summary.public_surface_raw_namespace_allowed", summary["public_surface_raw_namespace_allowed"], False)
|
||
assert_equal(
|
||
"gap.summary.work_session_transcript_public_allowed",
|
||
summary["work_session_transcript_public_allowed"],
|
||
False,
|
||
)
|
||
false_boundaries = gap["false_boundaries"]
|
||
for key, value in false_boundaries.items():
|
||
if value is not False:
|
||
fail(f"gap.false_boundaries.{key}: expected false, got {value!r}")
|
||
|
||
|
||
def validate_owner_packet(root: Path, spec: dict[str, Any]) -> None:
|
||
data = load_json(root / spec["path"])
|
||
label = spec["label"]
|
||
assert_equal(f"{label}.schema_version", data.get("schema_version"), spec["schema"])
|
||
assert_equal(f"{label}.status", data.get("status"), spec["status"])
|
||
assert_equal(f"{label}.runtime_execution_authorized", data.get("runtime_execution_authorized"), False)
|
||
for key, expected in spec["summary_counts"].items():
|
||
assert_equal(f"{label}.summary.{key}", summary_value(data, key), expected)
|
||
for key in spec["false_flags"]:
|
||
assert_false_summary_flag(label, data, key)
|
||
|
||
templates = data.get("response_templates", [])
|
||
if not isinstance(templates, list):
|
||
fail(f"{label}.response_templates: expected list")
|
||
assert_equal(f"{label}.response_templates.count", len(templates), spec["template_count"])
|
||
if "expected_template_ids" in spec:
|
||
template_ids = [item.get("template_id") or item.get("id") for item in templates]
|
||
for template_id in spec["expected_template_ids"]:
|
||
assert_contains(f"{label}.response_templates", template_ids, template_id)
|
||
|
||
|
||
def validate_rollup(root: Path) -> None:
|
||
rollup = load_json(root / "docs/security/source-control-owner-response-validation-rollup.snapshot.json")
|
||
summary = rollup["summary"]
|
||
assert_equal("rollup.schema_version", rollup["schema_version"], "source_control_owner_response_validation_rollup_v1")
|
||
assert_equal("rollup.status", rollup["status"], "draft_waiting_owner_responses")
|
||
assert_equal("rollup.summary.response_packet_count", summary["response_packet_count"], 4)
|
||
assert_equal("rollup.summary.total_response_template_count", summary["total_response_template_count"], 24)
|
||
assert_equal("rollup.summary.total_acceptance_check_count", summary["total_acceptance_check_count"], 32)
|
||
assert_equal("rollup.summary.total_rejection_rule_count", summary["total_rejection_rule_count"], 40)
|
||
assert_equal("rollup.summary.validation_lane_count", summary["validation_lane_count"], 4)
|
||
assert_equal("rollup.summary.owner_response_validation_reviewer_checklist_count", summary["owner_response_validation_reviewer_checklist_count"], 9)
|
||
assert_equal("rollup.summary.owner_response_validation_reviewer_outcome_lane_count", summary["owner_response_validation_reviewer_outcome_lane_count"], 7)
|
||
for key in [
|
||
"total_received_response_count",
|
||
"total_accepted_response_count",
|
||
"total_rejected_response_count",
|
||
"primary_ready_count",
|
||
]:
|
||
assert_equal(f"rollup.summary.{key}", summary[key], 0)
|
||
for key in [
|
||
"runtime_execution_authorized",
|
||
"action_buttons_allowed",
|
||
"repo_creation_authorized",
|
||
"visibility_change_authorized",
|
||
"refs_sync_authorized",
|
||
"refs_delete_authorized",
|
||
"workflow_modification_authorized",
|
||
"runner_enablement_authorized",
|
||
"secret_value_collection_allowed",
|
||
"github_primary_switch_authorized",
|
||
"force_push_authorized",
|
||
"write_token_allowed",
|
||
]:
|
||
assert_false_summary_flag("rollup", rollup, key)
|
||
|
||
|
||
def validate(root: Path) -> None:
|
||
validate_docs(root)
|
||
validate_gap_audit(root)
|
||
validate_rollup(root)
|
||
for spec in OWNER_PACKET_SPECS:
|
||
validate_owner_packet(root, spec)
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument(
|
||
"--root",
|
||
default=Path(__file__).resolve().parents[2],
|
||
type=Path,
|
||
help="Repository root. Defaults to the current script's repository.",
|
||
)
|
||
args = parser.parse_args()
|
||
validate(args.root.resolve())
|
||
print("IWOOOS_OWNER_GATE_GUARD_OK")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|