Files
awoooi/scripts/security/ssh-network-access-inventory.py
Your Name bc7e5e05ce
All checks were successful
CD Pipeline / tests (push) Successful in 1m31s
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / build-and-deploy (push) Successful in 4m25s
CD Pipeline / post-deploy-checks (push) Successful in 1m45s
feat(security): 新增 SSH network access 只讀清冊
2026-06-11 22:19:01 +08:00

410 lines
18 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 / network access repo-only 清冊。
本工具只讀取已提交的 repo 檔案,整理 SSH target、known_hosts policy、
sudoers wrapper、NetworkPolicy、NodePort、WireGuard runbook 與 SSH action rule。
它不 SSH、不 keyscan、不讀 live firewall、不套用 NetworkPolicy、不改
NodePort、不啟動 WireGuard、不執行 sudo 或任何主機命令。
"""
from __future__ import annotations
import argparse
import hashlib
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))
SURFACES: list[dict[str, Any]] = [
{
"surface_id": "ansible_inventory_ssh_targets",
"label": "Ansible inventory SSH targets",
"source_path": "infra/ansible/inventory/hosts.yml",
"expected_scope": "110_111_112_120_121_188",
"config_kind": "ssh_target_inventory",
"control_tier": "C1",
"current_state": "repo_source_visible_needs_pinned_host_key_policy",
"access_scope": ["192.168.0.110", "192.168.0.111", "192.168.0.112", "192.168.0.120", "192.168.0.121", "192.168.0.188"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 host owner、pinned known_hosts disposition、ProxyJump policy、key owner 與 rollback owner。",
},
{
"surface_id": "ansible_common_ssh_args",
"label": "Ansible common SSH host key policy",
"source_path": "infra/ansible/inventory/group_vars/all.yml",
"expected_scope": "multi_host",
"config_kind": "ssh_client_policy",
"control_tier": "C1",
"current_state": "accept_new_policy_visible_needs_owner_disposition",
"access_scope": ["StrictHostKeyChecking=accept-new", "ConnectTimeout=10"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "確認 accept-new 是否只限 bootstrap補升級 pinned known_hosts 的 owner 與時間窗。",
},
{
"surface_id": "gitea_cd_known_hosts_secret",
"label": "Gitea CD repair known_hosts secret",
"source_path": ".gitea/workflows/cd.yaml",
"expected_scope": "110_120_121_188_known_hosts",
"config_kind": "known_hosts_secret_workflow",
"control_tier": "C1",
"current_state": "repo_guard_visible_live_secret_not_verified",
"access_scope": ["192.168.0.110", "192.168.0.120", "192.168.0.121", "192.168.0.188"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 owner-provided known_hosts secret metadata、缺 120 時的處置、key rotation owner 與失敗通知 owner。",
},
{
"surface_id": "gitea_cd_deploy_ssh",
"label": "Gitea CD K8s deploy SSH path",
"source_path": ".gitea/workflows/cd.yaml",
"expected_scope": "k8s_ssh_host",
"config_kind": "ci_deploy_ssh",
"control_tier": "C1",
"current_state": "write_capable_deploy_path_visible_gate_closed",
"access_scope": ["K8S_SSH_HOST", "deploy_key", "kubectl apply", "ArgoCD sync"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 deploy SSH host owner、maintenance window、rollback owner、post-check 指標與 break-glass policy。",
},
{
"surface_id": "gitea_cd_dev_ssh",
"label": "Gitea CD dev deploy SSH path",
"source_path": ".gitea/workflows/cd-dev.yaml",
"expected_scope": "192.168.0.120",
"config_kind": "ci_deploy_ssh",
"control_tier": "C1",
"current_state": "dev_write_capable_deploy_path_visible_gate_closed",
"access_scope": ["192.168.0.120", "deploy_key", "kubectl apply"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "確認 dev deploy key scope、host key policy、rollback owner 與 dev/prod 邊界。",
},
{
"surface_id": "deploy_alerts_ssh_path",
"label": "Deploy alerts SSH path",
"source_path": ".gitea/workflows/deploy-alerts.yaml",
"expected_scope": "192.168.0.110",
"config_kind": "ci_deploy_ssh",
"control_tier": "C1",
"current_state": "write_capable_alert_deploy_path_visible_gate_closed",
"access_scope": ["192.168.0.110", "deploy alert scripts"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 alert deploy owner、known_hosts pinning、rollback owner、post-check 與通知路徑。",
},
{
"surface_id": "monitoring_discover_docker_ssh",
"label": "Monitoring Docker discovery SSH scanner",
"source_path": "ops/monitoring/discover_docker.py",
"expected_scope": "110_188_docker_hosts",
"config_kind": "ssh_discovery_script",
"control_tier": "C1",
"current_state": "accept_new_scanner_visible_needs_read_only_gate",
"access_scope": ["192.168.0.110", "192.168.0.188", "docker ps"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 scanner 執行 owner、read-only window、pinned known_hosts、輸出脫敏與失敗處置。",
},
{
"surface_id": "monitoring_exporter_deploy_ssh",
"label": "Monitoring exporter deploy SSH script",
"source_path": "ops/monitoring/deploy-exporters.sh",
"expected_scope": "192.168.0.188",
"config_kind": "monitoring_ssh_deploy_script",
"control_tier": "C1",
"current_state": "write_capable_script_visible_gate_closed",
"access_scope": ["192.168.0.188", "scp", "docker compose up -d"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 exporter deploy owner、maintenance window、rollback owner、host key policy 與 post-check 指標。",
},
{
"surface_id": "backup_config_ssh_capture",
"label": "Backup config SSH capture",
"source_path": "scripts/backup/backup-configs.sh",
"expected_scope": "110_188_120_121_cluster",
"config_kind": "ssh_backup_capture",
"control_tier": "C1",
"current_state": "read_capable_capture_visible_not_executed",
"access_scope": ["/etc/ssh", "/etc/nginx", "systemd", "docker", "k8s"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 backup execution owner、secret redaction proof、retention owner 與 restore validation。",
},
{
"surface_id": "host_ops_sudoers_wrapper",
"label": "Host ops sudoers wrapper",
"source_path": "scripts/host-ops/awoooi-wrapper.sudoers",
"expected_scope": "host_ops_minimal_sudo",
"config_kind": "sudoers_policy",
"control_tier": "C1",
"current_state": "sudoers_source_visible_needs_live_owner_evidence",
"access_scope": ["awoooi-hosts-add", "docker kill SIGHUP", "promtool", "amtool"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 live sudoers hash、visudo validation、command owner、rollback owner 與 forbidden command proof。",
},
{
"surface_id": "k8s_prod_network_policy",
"label": "K8s production NetworkPolicy",
"source_path": "k8s/awoooi-prod/02-network-policy.yaml",
"expected_scope": "awoooi_prod_namespace",
"config_kind": "k8s_network_policy",
"control_tier": "C1",
"current_state": "repo_policy_visible_needs_live_cluster_diff",
"access_scope": ["default deny", "ingress", "egress", "SSH egress", "Ollama", "monitoring"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 live NetworkPolicy diff、ingress / egress owner、rollback owner 與 route smoke。",
},
{
"surface_id": "argocd_metrics_network_policy",
"label": "ArgoCD metrics NetworkPolicy",
"source_path": "k8s/argocd/argocd-metrics-network-policy.yaml",
"expected_scope": "argocd_namespace",
"config_kind": "k8s_network_policy",
"control_tier": "C1",
"current_state": "repo_policy_visible_needs_prometheus_owner_confirmation",
"access_scope": ["192.168.0.188", "argocd metrics", "192.168.0.0/24 UI"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 Prometheus scrape owner、NodePort exposure owner、live policy diff 與 rollback owner。",
},
{
"surface_id": "argocd_metrics_nodeport",
"label": "ArgoCD metrics NodePort",
"source_path": "k8s/argocd/argocd-metrics-nodeport.yaml",
"expected_scope": "argocd_nodeport_30882_30883",
"config_kind": "k8s_nodeport_service",
"control_tier": "C1",
"current_state": "nodeport_source_visible_needs_exposure_review",
"access_scope": ["nodePort 30882", "nodePort 30883"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 NodePort exposure owner、firewall owner、Prometheus source whitelist 與 rollback owner。",
},
{
"surface_id": "velero_metrics_nodeport",
"label": "Velero metrics NodePort",
"source_path": "k8s/velero/velero-metrics-service.yaml",
"expected_scope": "velero_nodeport_30885",
"config_kind": "k8s_nodeport_service",
"control_tier": "C1",
"current_state": "nodeport_source_visible_needs_exposure_review",
"access_scope": ["nodePort 30885", "backup metrics"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 Velero metrics exposure owner、firewall owner、Prometheus source whitelist 與 rollback owner。",
},
{
"surface_id": "wireguard_mesh_runbook",
"label": "GCP Ollama WireGuard mesh runbook",
"source_path": "docs/runbooks/GCP-OLLAMA-WIREGUARD-MESH.md",
"expected_scope": "110_111_120_121_gcp_a_gcp_b",
"config_kind": "wireguard_runbook",
"control_tier": "C1",
"current_state": "target_architecture_documented_not_applied",
"access_scope": ["10.77.114.0/24", "51820/udp", "GCP-A", "GCP-B"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 WireGuard owner、public-key metadata、firewall rule owner、canary plan、rollback owner 與 cutover gate。",
},
{
"surface_id": "alert_rules_ssh_actions",
"label": "Alert rules SSH action surface",
"source_path": "apps/api/alert_rules.yaml",
"expected_scope": "ssh_mcp_action_catalog",
"config_kind": "alert_ssh_action_rules",
"control_tier": "C1",
"current_state": "ssh_action_catalog_visible_gate_closed",
"access_scope": ["ssh_diagnose", "docker restart", "systemctl restart", "docker compose", "docker prune"],
"requires_live_evidence": True,
"requires_owner_response": True,
"next_owner_action": "補 action owner、read/write/admin 分級、approval gate、cooldown、rollback owner 與 post-check 指標。",
},
]
FALSE_BOUNDARIES = {
"runtime_execution_authorized": False,
"host_write_authorized": False,
"ssh_read_authorized": False,
"ssh_write_authorized": False,
"sudo_action_authorized": False,
"firewall_change_authorized": False,
"network_policy_apply_authorized": False,
"nodeport_change_authorized": False,
"wireguard_change_authorized": False,
"known_hosts_patch_authorized": False,
"host_keyscan_authorized": False,
"live_host_read_authorized": False,
"secret_value_collection_allowed": False,
"ssh_key_collection_allowed": False,
"active_scan_authorized": False,
"action_buttons_allowed": False,
}
WRITE_CAPABLE_KINDS = {
"ci_deploy_ssh",
"monitoring_ssh_deploy_script",
"sudoers_policy",
"alert_ssh_action_rules",
}
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 file_metadata(root: Path, source_path: str) -> dict[str, Any]:
path = root / source_path
exists = path.exists()
if not exists:
return {"source_exists": False, "line_count": 0, "sha256": None}
content = path.read_bytes()
return {
"source_exists": True,
"line_count": len(content.decode("utf-8", errors="replace").splitlines()),
"sha256": hashlib.sha256(content).hexdigest(),
}
def build_surface(root: Path, surface: dict[str, Any]) -> dict[str, Any]:
metadata = file_metadata(root, surface["source_path"])
return {
**surface,
**metadata,
"owner_response_received": False,
"owner_response_accepted": False,
"live_evidence_received": False,
"maintenance_window_accepted": False,
"rollback_owner_accepted": False,
"runtime_gate_open": False,
"action_buttons_allowed": False,
}
def build_report(root: Path, generated_at: str | None) -> dict[str, Any]:
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
surfaces = [build_surface(root, surface) for surface in SURFACES]
expected_scopes = sorted({surface["expected_scope"] for surface in surfaces})
write_capable = [surface for surface in surfaces if surface["config_kind"] in WRITE_CAPABLE_KINDS]
ssh_sources = [surface for surface in surfaces if "ssh" in surface["config_kind"] or surface["config_kind"] in {"known_hosts_secret_workflow", "sudoers_policy"}]
network_policies = [surface for surface in surfaces if surface["config_kind"] == "k8s_network_policy"]
nodeports = [surface for surface in surfaces if surface["config_kind"] == "k8s_nodeport_service"]
wireguard = [surface for surface in surfaces if surface["config_kind"] == "wireguard_runbook"]
return {
"schema_version": "ssh_network_access_inventory_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"status": "repo_only_inventory_ready",
"source_scope": "committed_repo_files_only",
"summary": {
"surface_count": len(surfaces),
"source_exists_count": sum(1 for surface in surfaces if surface["source_exists"]),
"expected_scope_count": len(expected_scopes),
"ssh_source_surface_count": len(ssh_sources),
"network_policy_surface_count": len(network_policies),
"nodeport_surface_count": len(nodeports),
"sudoers_surface_count": sum(1 for surface in surfaces if surface["config_kind"] == "sudoers_policy"),
"wireguard_surface_count": len(wireguard),
"write_capable_surface_count": len(write_capable),
"surfaces_requiring_owner_response_count": sum(1 for surface in surfaces if surface["requires_owner_response"]),
"surfaces_requiring_live_evidence_count": sum(1 for surface in surfaces if surface["requires_live_evidence"]),
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"live_evidence_received_count": 0,
"maintenance_window_accepted_count": 0,
"rollback_owner_accepted_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
"coverage_percent_after_inventory": 54,
"coverage_percent_before_inventory": 48,
},
"execution_boundaries": FALSE_BOUNDARIES,
"expected_scopes": expected_scopes,
"access_surfaces": surfaces,
"write_capable_surfaces": [
{
"surface_id": surface["surface_id"],
"label": surface["label"],
"config_kind": surface["config_kind"],
"expected_scope": surface["expected_scope"],
"required_gate": "owner_response_plus_maintenance_window_plus_rollback_owner",
}
for surface in write_capable
],
"next_collection_order": [
"gitea_cd_known_hosts_secret",
"ansible_inventory_ssh_targets",
"host_ops_sudoers_wrapper",
"k8s_prod_network_policy",
"alert_rules_ssh_actions",
"argocd_metrics_nodeport",
"velero_metrics_nodeport",
"wireguard_mesh_runbook",
"monitoring_discover_docker_ssh",
"backup_config_ssh_capture",
],
"operator_interpretation": [
"這是 repo-only SSH / network access 清冊,不是 live host、firewall 或 cluster truth。",
"source_exists=true 只代表 repo 檔案存在;不代表 known_hosts、sudoers、NetworkPolicy、NodePort 或 WireGuard 已套用。",
"write-capable SSH / sudoers / alert action surface 可見代表需被管控,不代表 SSH、sudo、docker、systemctl 或 kubectl 已授權。",
"所有 live hash、pinned host key、maintenance window、rollback owner、firewall owner 與 post-check 指標都仍需 owner response。",
],
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="產生 IwoooS SSH / network access repo-only 清冊")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument("--output", help="輸出 JSON 檔")
parser.add_argument("--generated-at", help="固定 generated_at")
return parser.parse_args()
def main() -> int:
args = parse_args()
root = Path(args.root).resolve()
report = build_report(root, args.generated_at)
text = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.output:
Path(args.output).write_text(text + "\n", encoding="utf-8")
else:
print(text)
print(
"SSH_NETWORK_ACCESS_INVENTORY_OK "
f"surfaces={report['summary']['surface_count']} "
f"ssh_sources={report['summary']['ssh_source_surface_count']} "
f"nodeports={report['summary']['nodeport_surface_count']} "
f"runtime_gate={report['summary']['runtime_gate_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())