Some checks failed
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m38s
CD Pipeline / build-and-deploy (push) Successful in 5m32s
CD Pipeline / post-deploy-checks (push) Successful in 1m30s
Ansible / Reboot Recovery Contract / validate (push) Has been cancelled
334 lines
12 KiB
Python
334 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""驗證 Telegram 告警最後出口維持可讀、脫敏與 AI 事件卡格式。
|
||
|
||
本 guard 只讀 repo 內 source、test 與 snapshot。它不送 Telegram、不呼叫
|
||
Bot API、不讀 secret、不連線主機,也不啟動任何 runtime gate。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import subprocess
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
TAIPEI = timezone(timedelta(hours=8))
|
||
|
||
SNAPSHOT_PATH = Path("docs/security/telegram-alert-readability-guard.snapshot.json")
|
||
SOURCE_PATH = Path("apps/api/src/services/telegram_gateway.py")
|
||
TEST_PATH = Path("apps/api/tests/test_telegram_message_templates.py")
|
||
|
||
SOURCE_MARKERS = [
|
||
"def format_host_resource_alert_card",
|
||
"def format_aiops_signal_alert_card",
|
||
"def normalize_alert_notification_payload",
|
||
"def normalize_telegram_send_message_payload",
|
||
"_HOST_PROCESS_LINE_RE",
|
||
"_SECRET_ASSIGNMENT_RE",
|
||
"_BEARER_RE",
|
||
"_PRIVATE_IP_RE",
|
||
"_ABSOLUTE_PATH_RE",
|
||
"_URL_RE",
|
||
"payload = normalize_telegram_send_message_payload(method, payload)",
|
||
]
|
||
|
||
FINAL_EXIT_CONTRACTS = [
|
||
{
|
||
"exit_id": "telegram_gateway:_send_request",
|
||
"function_marker": "async def _send_request",
|
||
"required_marker": "normalize_telegram_send_message_payload",
|
||
},
|
||
{
|
||
"exit_id": "telegram_gateway:send_alert_notification",
|
||
"function_marker": "async def send_alert_notification",
|
||
"required_marker": "normalize_alert_notification_payload",
|
||
},
|
||
{
|
||
"exit_id": "telegram_gateway:send_text",
|
||
"function_marker": "async def send_text",
|
||
"required_marker": "normalize_alert_notification_payload",
|
||
},
|
||
]
|
||
|
||
TEST_CONTRACTS = [
|
||
"test_ci_runner_load_alert_becomes_capacity_event_packet",
|
||
"test_wazuh_alert_becomes_aiops_signal_event_packet",
|
||
"test_wazuh_dashboard_api_degraded_alert_becomes_readback_gap_event_packet",
|
||
"test_nginx_drift_alert_becomes_public_gateway_event_packet",
|
||
"test_aiops_signal_formatter_covers_non_host_alert_lanes",
|
||
"test_send_alert_notification_normalizes_host_resource_raw_dump",
|
||
"test_send_alert_notification_normalizes_aiops_signal_alert",
|
||
"test_prisma_generate_alert_redacts_raw_process_json_and_urls",
|
||
"test_send_alert_notification_forces_html_card_for_markdown_host_alert",
|
||
"test_send_text_normalizes_host_resource_alert",
|
||
"test_send_request_payload_normalizer_blocks_direct_host_raw_dump",
|
||
]
|
||
|
||
AI_SIGNAL_LANES = [
|
||
"wazuh_dashboard_api_readback_degraded",
|
||
"wazuh_intrusion_signal",
|
||
"kali_assessment_signal",
|
||
"nginx_config_drift",
|
||
"backup_restore_escrow_signal",
|
||
"provider_freshness_signal",
|
||
"supply_chain_drift",
|
||
]
|
||
|
||
HOST_RESOURCE_LANES = [
|
||
"orphan_browser_smoke_runaway_process",
|
||
"ci_runner_load_saturation",
|
||
"runner_prisma_generate_resource_pressure",
|
||
"runner_build_resource_pressure",
|
||
"node_process_resource_pressure",
|
||
"host_resource_pressure_triage",
|
||
]
|
||
|
||
PRIVATE_IP_SOURCE_MARKER = ".".join(["192", "168", "0", "110"])
|
||
CHECKPOINT_SOURCE_MARKER = ".".join(["checkpoint", "prisma", "io"])
|
||
TOOLCACHE_SOURCE_MARKER = "/" + "/".join(["opt", "hostedtoolcache"])
|
||
WORKSPACE_SOURCE_MARKER = "/" + "/".join(["workspace", "wooo"])
|
||
SECRET_SOURCE_MARKER = "super" + "-secret"
|
||
TOKEN_LIKE_SOURCE_MARKER = "abcd" + "efghijkl"
|
||
PRISMA_JSON_SOURCE_MARKER = '"' + "product" + '":"' + "prisma" + '"'
|
||
|
||
BLOCKED_RAW_OUTPUT_SOURCE_MARKERS = [
|
||
"root 311",
|
||
"root 365",
|
||
CHECKPOINT_SOURCE_MARKER,
|
||
"node_modules",
|
||
TOOLCACHE_SOURCE_MARKER,
|
||
WORKSPACE_SOURCE_MARKER,
|
||
"/var/ossec",
|
||
"/etc/nginx",
|
||
SECRET_SOURCE_MARKER,
|
||
TOKEN_LIKE_SOURCE_MARKER,
|
||
PRIVATE_IP_SOURCE_MARKER,
|
||
PRISMA_JSON_SOURCE_MARKER,
|
||
]
|
||
|
||
BLOCKED_RAW_OUTPUT_MARKERS = [
|
||
"root-process-line",
|
||
"root-prisma-process-line",
|
||
"external-checkpoint-url",
|
||
"package-tree-path",
|
||
"hosted-toolcache-path",
|
||
"workspace-path",
|
||
"raw-wazuh-path",
|
||
"raw-nginx-path",
|
||
"secret-placeholder",
|
||
"token-like-placeholder",
|
||
"internal-host-ip",
|
||
"raw-prisma-json-product-marker",
|
||
]
|
||
|
||
REQUIRED_OUTPUT_MARKERS = [
|
||
"ai_automation_alert_card_v1",
|
||
"AI 自動化判讀",
|
||
"controlled_playbook_queue",
|
||
"runtime_write_gate=controlled",
|
||
"Top evidence",
|
||
"禁止事項",
|
||
"allowlisted PlayBook",
|
||
]
|
||
|
||
EXECUTION_BOUNDARIES = {
|
||
"not_authorization": True,
|
||
"telegram_send_authorized": False,
|
||
"bot_api_call_authorized": False,
|
||
"direct_bot_api_migration_authorized": False,
|
||
"workflow_modification_authorized": False,
|
||
"ops_script_modification_authorized": False,
|
||
"api_sender_refactor_authorized": False,
|
||
"secret_value_collection_allowed": False,
|
||
"raw_payload_storage_allowed": False,
|
||
"host_write_authorized": False,
|
||
"production_write_authorized": False,
|
||
"runtime_execution_authorized": False,
|
||
"runtime_gate_open": False,
|
||
"action_buttons_allowed": False,
|
||
}
|
||
|
||
|
||
def read_text(root: Path, relative_path: Path) -> str:
|
||
path = root / relative_path
|
||
if not path.exists():
|
||
raise SystemExit(f"BLOCKED missing file: {relative_path}")
|
||
return path.read_text(encoding="utf-8")
|
||
|
||
|
||
def require_contains(label: str, text: str, marker: str) -> None:
|
||
if marker not in text:
|
||
raise SystemExit(f"BLOCKED {label}: missing {marker!r}")
|
||
|
||
|
||
def function_segment(text: str, marker: str, *, limit: int = 2200) -> str:
|
||
start = text.find(marker)
|
||
if start == -1:
|
||
raise SystemExit(f"BLOCKED source function marker missing: {marker!r}")
|
||
return text[start : start + limit]
|
||
|
||
|
||
def git_commit(root: Path) -> str:
|
||
try:
|
||
return subprocess.check_output(
|
||
["git", "rev-parse", "--short", "HEAD"],
|
||
cwd=root,
|
||
text=True,
|
||
stderr=subprocess.DEVNULL,
|
||
).strip()
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
|
||
def validate_source_contract(root: Path) -> None:
|
||
source = read_text(root, SOURCE_PATH)
|
||
tests = read_text(root, TEST_PATH)
|
||
|
||
for marker in SOURCE_MARKERS:
|
||
require_contains("telegram_gateway.source", source, marker)
|
||
|
||
for contract in FINAL_EXIT_CONTRACTS:
|
||
segment = function_segment(source, contract["function_marker"])
|
||
require_contains(
|
||
f"{contract['exit_id']}.normalizer",
|
||
segment,
|
||
contract["required_marker"],
|
||
)
|
||
|
||
for marker in TEST_CONTRACTS:
|
||
require_contains("telegram_message_templates.tests", tests, marker)
|
||
|
||
for marker in AI_SIGNAL_LANES:
|
||
require_contains("telegram_gateway.ai_signal_lanes", source, marker)
|
||
|
||
for marker in HOST_RESOURCE_LANES:
|
||
require_contains("telegram_gateway.host_resource_lanes", source, marker)
|
||
|
||
for marker in BLOCKED_RAW_OUTPUT_SOURCE_MARKERS:
|
||
require_contains("telegram_message_templates.blocked_raw_outputs", tests, marker)
|
||
|
||
for marker in REQUIRED_OUTPUT_MARKERS:
|
||
require_contains("telegram_message_templates.required_output_markers", tests, marker)
|
||
|
||
|
||
def build_snapshot(root: Path) -> dict[str, Any]:
|
||
validate_source_contract(root)
|
||
return {
|
||
"schema_version": "telegram_alert_readability_guard_v1",
|
||
"status": "telegram_alert_readability_guard_ready_no_runtime_action",
|
||
"generated_at": datetime.now(TAIPEI).replace(microsecond=0).isoformat(),
|
||
"git_commit": git_commit(root),
|
||
"source_refs": [str(SOURCE_PATH), str(TEST_PATH)],
|
||
"source_markers": SOURCE_MARKERS,
|
||
"final_exit_contracts": FINAL_EXIT_CONTRACTS,
|
||
"test_contracts": TEST_CONTRACTS,
|
||
"ai_signal_lanes": AI_SIGNAL_LANES,
|
||
"host_resource_lanes": HOST_RESOURCE_LANES,
|
||
"blocked_raw_output_markers": BLOCKED_RAW_OUTPUT_MARKERS,
|
||
"required_output_markers": REQUIRED_OUTPUT_MARKERS,
|
||
"execution_boundaries": EXECUTION_BOUNDARIES,
|
||
"summary": {
|
||
"source_formatter_marker_count": len(SOURCE_MARKERS),
|
||
"final_exit_contract_count": len(FINAL_EXIT_CONTRACTS),
|
||
"test_contract_count": len(TEST_CONTRACTS),
|
||
"ai_signal_lane_count": len(AI_SIGNAL_LANES),
|
||
"host_resource_lane_count": len(HOST_RESOURCE_LANES),
|
||
"blocked_raw_output_marker_count": len(BLOCKED_RAW_OUTPUT_MARKERS),
|
||
"required_output_marker_count": len(REQUIRED_OUTPUT_MARKERS),
|
||
"telegram_send_authorized_count": 0,
|
||
"bot_api_call_authorized_count": 0,
|
||
"raw_payload_storage_allowed_count": 0,
|
||
"secret_value_collection_allowed_count": 0,
|
||
"production_write_authorized_count": 0,
|
||
"runtime_gate_count": 0,
|
||
"action_button_count": 0,
|
||
},
|
||
}
|
||
|
||
|
||
def validate_snapshot(snapshot: dict[str, Any]) -> None:
|
||
expected_summary = {
|
||
"source_formatter_marker_count": len(SOURCE_MARKERS),
|
||
"final_exit_contract_count": len(FINAL_EXIT_CONTRACTS),
|
||
"test_contract_count": len(TEST_CONTRACTS),
|
||
"ai_signal_lane_count": len(AI_SIGNAL_LANES),
|
||
"host_resource_lane_count": len(HOST_RESOURCE_LANES),
|
||
"blocked_raw_output_marker_count": len(BLOCKED_RAW_OUTPUT_MARKERS),
|
||
"required_output_marker_count": len(REQUIRED_OUTPUT_MARKERS),
|
||
"telegram_send_authorized_count": 0,
|
||
"bot_api_call_authorized_count": 0,
|
||
"raw_payload_storage_allowed_count": 0,
|
||
"secret_value_collection_allowed_count": 0,
|
||
"production_write_authorized_count": 0,
|
||
"runtime_gate_count": 0,
|
||
"action_button_count": 0,
|
||
}
|
||
if snapshot.get("schema_version") != "telegram_alert_readability_guard_v1":
|
||
raise SystemExit("BLOCKED telegram alert readability schema mismatch")
|
||
if snapshot.get("status") != "telegram_alert_readability_guard_ready_no_runtime_action":
|
||
raise SystemExit("BLOCKED telegram alert readability status mismatch")
|
||
for key, expected in expected_summary.items():
|
||
actual = snapshot.get("summary", {}).get(key)
|
||
if actual != expected:
|
||
raise SystemExit(f"BLOCKED telegram alert readability summary.{key}: expected {expected!r}, got {actual!r}")
|
||
for key, expected in EXECUTION_BOUNDARIES.items():
|
||
actual = snapshot.get("execution_boundaries", {}).get(key)
|
||
if actual is not expected:
|
||
raise SystemExit(f"BLOCKED telegram alert readability boundary.{key}: expected {expected!r}, got {actual!r}")
|
||
for list_key, expected in [
|
||
("source_markers", SOURCE_MARKERS),
|
||
("test_contracts", TEST_CONTRACTS),
|
||
("ai_signal_lanes", AI_SIGNAL_LANES),
|
||
("host_resource_lanes", HOST_RESOURCE_LANES),
|
||
("blocked_raw_output_markers", BLOCKED_RAW_OUTPUT_MARKERS),
|
||
("required_output_markers", REQUIRED_OUTPUT_MARKERS),
|
||
]:
|
||
actual = snapshot.get(list_key)
|
||
if actual != expected:
|
||
raise SystemExit(f"BLOCKED telegram alert readability {list_key} drift")
|
||
|
||
|
||
def validate(root: Path) -> None:
|
||
validate_source_contract(root)
|
||
snapshot_path = root / SNAPSHOT_PATH
|
||
if not snapshot_path.exists():
|
||
raise SystemExit(f"BLOCKED missing snapshot: {SNAPSHOT_PATH}")
|
||
validate_snapshot(json.loads(snapshot_path.read_text(encoding="utf-8")))
|
||
|
||
|
||
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 this script's repository.",
|
||
)
|
||
parser.add_argument(
|
||
"--write",
|
||
action="store_true",
|
||
help="Write the committed snapshot JSON.",
|
||
)
|
||
args = parser.parse_args()
|
||
root = args.root.resolve()
|
||
if args.write:
|
||
snapshot = build_snapshot(root)
|
||
target = root / SNAPSHOT_PATH
|
||
target.write_text(
|
||
json.dumps(snapshot, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
validate(root)
|
||
print(
|
||
"TELEGRAM_ALERT_READABILITY_GUARD_OK "
|
||
f"tests={len(TEST_CONTRACTS)} ai_lanes={len(AI_SIGNAL_LANES)} "
|
||
f"host_lanes={len(HOST_RESOURCE_LANES)} runtime_gate=0"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|