Files
awoooi/scripts/security/ssh-network-owner-response-acceptance.py
Your Name 33b4608117
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / tests (push) Successful in 1m31s
CD Pipeline / build-and-deploy (push) Successful in 4m13s
CD Pipeline / post-deploy-checks (push) Successful in 2m2s
fix(iwooos): 新增 ssh network owner acceptance ledger
2026-06-14 21:52:13 +08:00

361 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
IwoooS SSH / Firewall / Network Access owner response acceptance 只讀帳本產生器。
本工具讀取 SSH / network access repo-only 清冊與 owner request draft建立
未來 owner response 如何收件、補件、隔離、拒收或進 network / firewall
review 的 metadata-only acceptance ledger。它不 SSH、不 keyscan、不讀 live
firewall、不 patch known_hosts、不套用 NetworkPolicy、不改 NodePort、不啟動
WireGuard、不收 secret 或 SSH key value。
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
TAIPEI = timezone(timedelta(hours=8))
ACCEPTANCE_FIELDS = [
"acceptance_candidate_id",
"request_id",
"surface_id",
"config_kind",
"expected_scope",
"access_scope",
"control_tier",
"write_capable_surface",
"owner_response_ref",
"owner_role_or_team",
"decision",
"decision_reason",
"affected_scope",
"redacted_evidence_refs",
"live_access_state_ref",
"allowed_source_cidrs_ref",
"host_key_pinning_ref",
"port_policy_ref",
"network_policy_diff_ref",
"nodeport_exposure_ref",
"firewall_owner",
"maintenance_window",
"rollback_owner",
"validation_plan",
"break_glass_owner",
"change_freeze_rule",
"reviewer_outcome",
"followup_owner",
"not_approval",
]
REVIEWER_CHECKS = [
{"check_id": "owner_identity_present", "instruction": "owner role / team 必須可追溯。"},
{"check_id": "decision_reason_present", "instruction": "decision 與 decision reason 必須同時存在。"},
{"check_id": "affected_scope_matches_surface", "instruction": "affected scope 必須能對回 committed surface_id。"},
{"check_id": "redacted_refs_only", "instruction": "evidence 只能是脫敏 ref、hash、ticket、commit 或 artifact pointer。"},
{"check_id": "secret_or_key_value_absent", "instruction": "不得出現 private key、SSH key、token、password、cookie 或 partial secret。"},
{"check_id": "live_access_state_metadata_only", "instruction": "live access state 只能是 owner-provided metadata ref不得貼 raw firewall dump。"},
{"check_id": "allowed_source_cidr_metadata_only", "instruction": "allowed source CIDR 只能是 policy ref 或脫敏摘要,不得暴露敏感來源明細。"},
{"check_id": "host_key_pinning_shape", "instruction": "known_hosts / host key pinning 只能收 fingerprint ref不得自動 keyscan 或 patch。"},
{"check_id": "port_impact_review", "instruction": "port close / open 影響範圍必須列出 public route、admin route、agent、monitoring 與 rollback。"},
{"check_id": "firewall_owner_present", "instruction": "firewall owner、rollback owner 與 change freeze rule 必須存在。"},
{"check_id": "network_policy_nodeport_review", "instruction": "NetworkPolicy / NodePort 需有 exposure owner、source whitelist 與 route smoke plan。"},
{"check_id": "wireguard_cutover_separate_gate", "instruction": "WireGuard cutover 必須獨立維護窗口與 runtime gate不得混入 owner acceptance。"},
{"check_id": "maintenance_window_present", "instruction": "任何未來端口、firewall、NodePort 或 WireGuard 變更都必須另有維護窗口。"},
{"check_id": "rollback_validation_present", "instruction": "rollback owner 與 validation plan 必須同時存在。"},
{"check_id": "counts_transition_safe", "instruction": "只有 reviewer record 可更新 received / accepted / rejected不得同時開 runtime gate。"},
]
OUTCOME_LANES = [
{"lane_id": "waiting_owner_response", "meaning": "尚未收到 owner response所有 accepted / runtime count 維持 0。"},
{"lane_id": "quarantine_raw_payload", "meaning": "收到 raw firewall dump、SSH key、private key、token 或不可保存內容時只能隔離。"},
{"lane_id": "reject_secret_or_key_value", "meaning": "出現 secret value、key material、credential derivative 或未脫敏 payload 時直接拒收。"},
{"lane_id": "request_supplement", "meaning": "欄位不足、scope 不清、CIDR / owner / rollback / validation 缺失時要求補件。"},
{"lane_id": "ready_for_network_review", "meaning": "metadata 合格後,只能進 network / firewall reviewer review。"},
{"lane_id": "owner_review_only_update", "meaning": "只允許更新只讀 owner review ledger不得改 port、firewall、known_hosts 或 policy。"},
{"lane_id": "waiting_runtime_gate", "meaning": "即使 owner response acceptedruntime gate 仍等待獨立人工批准。"},
]
BLOCKED_ACTIONS = [
"ssh_read",
"ssh_write",
"host_keyscan",
"known_hosts_patch",
"firewall_change",
"port_close",
"port_open",
"network_policy_apply",
"nodeport_change",
"wireguard_change",
"sudo_action",
"deploy_ssh_action",
"secret_value_collection",
"ssh_key_collection",
"active_scan",
"runtime_gate_open",
"live_firewall_read",
"live_sudoers_read",
"raw_key_material_storage",
"raw_firewall_dump_storage",
"mark_owner_response_accepted_without_reviewer_record",
"add_action_button",
]
def git_short_sha(root: Path) -> str:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=root,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
except Exception:
return "unknown"
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def acceptance_candidate(request: dict[str, Any]) -> dict[str, Any]:
surface_id = request["surface_id"]
return {
"acceptance_candidate_id": f"ssh_network_owner_response_acceptance:{surface_id}",
"status": "waiting_owner_response",
"request_id": request["request_id"],
"surface_id": surface_id,
"config_kind": request["config_kind"],
"expected_scope": request["expected_scope"],
"access_scope": request["access_scope"],
"control_tier": request["control_tier"],
"write_capable_surface": request["write_capable_surface"],
"owner_response_ref": None,
"owner_role_or_team": "pending_owner_response",
"decision": "pending_owner_response",
"decision_reason": "pending_owner_response",
"affected_scope": "pending_owner_response",
"redacted_evidence_refs": [],
"live_access_state_ref": None,
"allowed_source_cidrs_ref": None,
"host_key_pinning_ref": None,
"port_policy_ref": None,
"network_policy_diff_ref": None,
"nodeport_exposure_ref": None,
"firewall_owner": "pending_owner_response",
"maintenance_window": "pending_owner_response",
"rollback_owner": "pending_owner_response",
"validation_plan": "pending_owner_response",
"break_glass_owner": "pending_owner_response",
"change_freeze_rule": "pending_owner_response",
"reviewer_outcome": "waiting_owner_response",
"followup_owner": "pending_owner_response",
"acceptance_fields": ACCEPTANCE_FIELDS,
"required_owner_fields": request["required_owner_fields"],
"reviewer_checks": [item["check_id"] for item in REVIEWER_CHECKS],
"outcome_lanes": [item["lane_id"] for item in OUTCOME_LANES],
"blocked_actions": BLOCKED_ACTIONS,
"not_approval": True,
"request_sent": False,
"recipient_confirmed": False,
"owner_response_received": False,
"owner_response_accepted": False,
"owner_response_rejected": False,
"owner_response_quarantined": False,
"supplement_requested": False,
"live_evidence_received": False,
"maintenance_window_accepted": False,
"rollback_owner_accepted": False,
"validation_plan_accepted": False,
"firewall_owner_accepted": False,
"host_key_pinning_accepted": False,
"port_policy_accepted": False,
"network_policy_diff_accepted": False,
"nodeport_exposure_accepted": False,
"wireguard_cutover_accepted": False,
"host_write_authorized": False,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"host_keyscan_authorized": False,
"known_hosts_patch_authorized": False,
"firewall_change_authorized": False,
"port_change_authorized": False,
"port_close_authorized": False,
"port_open_authorized": False,
"network_policy_apply_authorized": False,
"nodeport_change_authorized": False,
"wireguard_change_authorized": False,
"sudo_action_authorized": False,
"deploy_ssh_action_authorized": False,
"secret_value_collection_allowed": False,
"ssh_key_collection_allowed": False,
"active_scan_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
}
def build_report(
root: Path,
inventory: dict[str, Any],
request_draft_report: dict[str, Any],
generated_at: str | None,
) -> dict[str, Any]:
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
requests = request_draft_report.get("request_drafts", [])
acceptance_candidates = [acceptance_candidate(item) for item in requests]
write_capable = [item for item in acceptance_candidates if item["write_capable_surface"]]
live_evidence = [item for item in acceptance_candidates if item["live_evidence_received"] is False]
return {
"schema_version": "ssh_network_owner_response_acceptance_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"source_inventory_schema_version": inventory.get("schema_version"),
"source_inventory_status": inventory.get("status"),
"source_owner_request_schema_version": request_draft_report.get("schema_version"),
"source_owner_request_status": request_draft_report.get("status"),
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"summary": {
"source_surface_count": inventory.get("summary", {}).get("surface_count", 0),
"source_request_draft_count": request_draft_report.get("summary", {}).get("request_draft_count", 0),
"acceptance_candidate_count": len(acceptance_candidates),
"write_capable_acceptance_candidate_count": len(write_capable),
"live_evidence_required_candidate_count": len(live_evidence),
"acceptance_field_count": len(ACCEPTANCE_FIELDS),
"required_owner_field_count": len(request_draft_report["required_owner_fields"]),
"reviewer_check_count": len(REVIEWER_CHECKS),
"outcome_lane_count": len(OUTCOME_LANES),
"blocked_action_count": len(BLOCKED_ACTIONS),
"request_sent_count": 0,
"recipient_confirmed_count": 0,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"owner_response_rejected_count": 0,
"owner_response_quarantined_count": 0,
"supplement_requested_count": 0,
"live_evidence_received_count": 0,
"maintenance_window_accepted_count": 0,
"rollback_owner_accepted_count": 0,
"validation_plan_accepted_count": 0,
"firewall_owner_accepted_count": 0,
"host_key_pinning_accepted_count": 0,
"port_policy_accepted_count": 0,
"network_policy_diff_accepted_count": 0,
"nodeport_exposure_accepted_count": 0,
"wireguard_cutover_accepted_count": 0,
"host_write_authorized_count": 0,
"ssh_read_authorized_count": 0,
"ssh_write_authorized_count": 0,
"host_keyscan_authorized_count": 0,
"known_hosts_patch_authorized_count": 0,
"firewall_change_authorized_count": 0,
"port_change_authorized_count": 0,
"port_close_authorized_count": 0,
"port_open_authorized_count": 0,
"network_policy_apply_authorized_count": 0,
"nodeport_change_authorized_count": 0,
"wireguard_change_authorized_count": 0,
"sudo_action_authorized_count": 0,
"deploy_ssh_action_authorized_count": 0,
"secret_value_collection_allowed_count": 0,
"ssh_key_collection_allowed_count": 0,
"active_scan_authorized_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
},
"execution_boundaries": {
"request_dispatch_authorized": False,
"owner_response_accepted": False,
"live_evidence_received": False,
"live_host_read_authorized": False,
"host_write_authorized": False,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"host_keyscan_authorized": False,
"known_hosts_patch_authorized": False,
"firewall_change_authorized": False,
"port_change_authorized": False,
"port_close_authorized": False,
"port_open_authorized": False,
"network_policy_apply_authorized": False,
"nodeport_change_authorized": False,
"wireguard_change_authorized": False,
"sudo_action_authorized": False,
"deploy_ssh_action_authorized": False,
"secret_value_collection_allowed": False,
"ssh_key_collection_allowed": False,
"active_scan_authorized": False,
"runtime_execution_authorized": False,
"action_buttons_allowed": False,
"not_authorization": True,
},
"acceptance_fields": ACCEPTANCE_FIELDS,
"required_owner_fields": request_draft_report["required_owner_fields"],
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"acceptance_candidates": acceptance_candidates,
"next_steps": [
"等待 owner response未收到前不得更新 accepted count。",
"收到回覆後先走 raw payload / secret / key material / scope / CIDR / port impact / rollback 檢查,不合格即隔離、拒收或補件。",
"metadata 合格也只能進 network / firewall reviewer reviewSSH、keyscan、known_hosts patch、firewall、port、NetworkPolicy、NodePort 與 WireGuard 仍需獨立人工批准。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="IwoooS SSH / Firewall / Network Access owner response acceptance 只讀帳本產生器")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument(
"--inventory-report",
default="docs/security/ssh-network-access-inventory.snapshot.json",
help="ssh-network-access-inventory.py 輸出的 JSON",
)
parser.add_argument(
"--owner-request-report",
default="docs/security/ssh-network-owner-request-draft.snapshot.json",
help="ssh-network-owner-request-draft.py 輸出的 JSON",
)
parser.add_argument("--output", help="寫出 JSON 報告")
parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用")
args = parser.parse_args()
root = Path(args.root).resolve()
inventory = load_json(root / args.inventory_report)
request_draft_report = load_json(root / args.owner_request_report)
report = build_report(root, inventory, request_draft_report, args.generated_at)
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.output:
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(payload + "\n", encoding="utf-8")
else:
print(payload)
summary = report["summary"]
print(
"SSH_NETWORK_OWNER_RESPONSE_ACCEPTANCE_OK "
f"candidates={summary['acceptance_candidate_count']} "
f"write_capable={summary['write_capable_acceptance_candidate_count']} "
f"checks={summary['reviewer_check_count']} "
f"lanes={summary['outcome_lane_count']} "
f"accepted={summary['owner_response_accepted_count']} "
f"runtime_gate={summary['runtime_gate_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())