Files
awoooi/scripts/security/telegram-notification-egress-inventory.py

921 lines
32 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
"""Build a repo-only Telegram notification egress inventory.
This scanner identifies guarded Telegram Bot API paths that can bypass
TelegramGateway's final-exit formatter. Local ``.github/workflows`` files are
audited only as frozen legacy source truth. It does not read secrets, call
Telegram, modify or execute workflows, or send notifications.
"""
from __future__ import annotations
import argparse
import ast
import hashlib
import json
import re
import subprocess
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
TAIPEI = timezone(timedelta(hours=8))
SCAN_ROOTS = (
Path("agent99-control-plane.ps1"),
Path(".gitea/workflows"),
Path(".github/workflows"),
Path("scripts/ops"),
Path("scripts/ci"),
Path("scripts/reboot-recovery"),
Path("apps/api/src"),
)
SCAN_SUFFIXES = {".py", ".ps1", ".sh", ".js", ".yml", ".yaml"}
GUARDED_BOT_METHODS = (
"sendMessage",
"sendDocument",
"sendPhoto",
"sendMediaGroup",
"editMessageText",
"sendAnimation",
"sendVideo",
"sendAudio",
"sendVoice",
)
BOT_TOKEN_URL_RE = re.compile(
r"api\.telegram\.org/bot.*?/(?P<method>"
+ "|".join(re.escape(method) for method in GUARDED_BOT_METHODS)
+ r")\b",
re.IGNORECASE,
)
GUARDED_BOT_METHOD_RE = re.compile(
r"\b(?P<method>"
+ "|".join(re.escape(method) for method in GUARDED_BOT_METHODS)
+ r")\b",
re.IGNORECASE,
)
COMPACT_BOT_ENDPOINT_RE = re.compile(
r"api\.telegram\.org.{0,1024}?bot.{0,512}?/(?P<method>"
+ "|".join(re.escape(method) for method in GUARDED_BOT_METHODS)
+ r")",
re.IGNORECASE,
)
DIRECT_HTTP_TRANSPORT_RE = re.compile(
r"(?:"
r"requests\s*\.\s*Session\s*\([^)]*\)\s*\.\s*(?:post|request|send)\s*\(|"
r"requests\s*\.\s*(?:post|request)\s*\(|"
r"httpx\s*\.\s*(?:AsyncClient|Client)\s*\([^)]*\)\s*\.\s*(?:post|request|send)\s*\(|"
r"httpx\s*\.\s*(?:post|request)\s*\(|"
r"(?:urllib\s*\.\s*request\s*\.\s*)?build_opener\s*\([^)]*\)\s*\.\s*open\s*\(|"
r"urllib\s*\.\s*request\s*\.\s*(?:urlopen|Request)\s*\(|"
r"\burlopen\s*\(|"
r"\b(?:_?opener)\s*\.\s*open\s*\(|"
r"\bInvoke-(?:WebRequest|RestMethod)\b|"
r"\.\s*PostAsync\s*\(|"
r"\.\s*Upload(?:String|Data|File)\s*\(|"
r"\b(?:fetch|curl|wget)\b|"
r"\b(?:_http_client|client|session)\s*\.\s*(?:post|request|send)\s*\("
r")",
re.IGNORECASE,
)
TELEGRAM_CONTEXT_RE = re.compile(
r"(?:telegram|bot[_-]?token|chat[_-]?id)", re.IGNORECASE
)
BOT_TOKEN_CONTEXT_RE = re.compile(
r"(?:\bbot[_-]?token\b|\btelegram[_-]?(?:bot[_-]?)?token\b|\$Token\b)",
re.IGNORECASE,
)
CHAT_CONTEXT_RE = re.compile(
r"(?:\bchat[_-]?id\b|\$ChatId\b)",
re.IGNORECASE,
)
POWERSHELL_FUNCTION_RE = re.compile(
r"^function\s+(?P<name>[A-Za-z0-9_-]+)\s*\{", re.IGNORECASE | re.MULTILINE
)
CANONICAL_FINAL_EXITS = {
(
"apps/api/src/services/telegram_gateway.py",
"TelegramGateway._send_request",
),
}
_AST_IMPORT_NODE_TYPES = (ast.Import, ast.ImportFrom)
_AST_FUNCTION_NODE_TYPES = (ast.FunctionDef, ast.AsyncFunctionDef)
_AST_SEQUENCE_TARGET_NODE_TYPES = (ast.Tuple, ast.List)
_AST_NON_MODULE_SCOPE_NODE_TYPES = (
ast.ClassDef,
ast.FunctionDef,
ast.AsyncFunctionDef,
ast.Lambda,
)
GATEWAY_CALLSITE_RE = re.compile(
r"(?:send_alert_notification\(|\b(?:tg|gw|gateway|telegram)\.send_text\(|_send_request\(\s*[\"']sendMessage[\"'])"
)
SECRET_INTERPOLATION_RE = re.compile(r"\$\{\{\s*secrets\.[^}]+\}\}")
BOT_TOKEN_FRAGMENT_RE = re.compile(
r"bot[^/\s\"']+/(?P<method>"
+ "|".join(re.escape(method) for method in GUARDED_BOT_METHODS)
+ r")\b",
re.IGNORECASE,
)
REQUIRED_OWNER_FIELDS = [
"egress_surface_id",
"owner_role_or_team",
"routing_purpose",
"current_sender",
"target_chat_route",
"message_shape_contract",
"redaction_contract",
"formatter_convergence_plan",
"delivery_receipt_ref",
"dedup_or_fingerprint_plan",
"fallback_or_degraded_mode",
"migration_or_exception_reason",
"maintenance_window",
"rollback_owner",
"postcheck_evidence_ref",
"no_secret_value_attestation",
"no_raw_payload_attestation",
"no_false_green_attestation",
]
REVIEWER_CHECKS = [
"direct_bot_api_surface_identified",
"owner_role_present",
"target_route_is_sre_owned",
"message_shape_is_ai_automation_card_or_documented_exception",
"redaction_contract_present",
"formatter_convergence_path_present",
"delivery_receipt_metadata_only",
"dedup_or_fingerprint_present",
"fallback_mode_does_not_leak_raw_payload",
"secret_name_only_no_value",
"workflow_or_script_change_requires_separate_approval",
"telegram_send_not_executed_by_inventory",
"no_false_green_claim",
"runtime_gate_stays_zero",
]
OUTCOME_LANES = [
"waiting_owner_response",
"request_owner_route_supplement",
"request_formatter_convergence_plan",
"request_redaction_contract",
"request_delivery_receipt_metadata",
"quarantine_secret_or_raw_payload",
"reject_false_green_claim",
"ready_for_notification_egress_review",
"waiting_runtime_gate",
]
BLOCKED_ACTIONS = [
"telegram_send",
"bot_api_call",
"workflow_modification",
"script_modification_without_owner",
"secret_value_collection",
"secret_hash_collection",
"partial_token_collection",
"chat_id_collection_without_owner",
"store_raw_message_payload",
"store_unredacted_workflow_log",
"change_chat_route",
"change_bot_token",
"rotate_secret",
"workflow_dispatch",
"production_deploy",
"accept_route_200_as_delivery_receipt",
"accept_cd_success_as_notification_acceptance",
"accept_ui_visible_as_notification_acceptance",
"skip_formatter_convergence",
"skip_redaction_review",
"open_runtime_gate",
"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 iter_scannable_files(root: Path) -> list[Path]:
files: list[Path] = []
for scan_root in SCAN_ROOTS:
absolute_root = root / scan_root
if not absolute_root.exists():
continue
if absolute_root.is_file():
if absolute_root.suffix in SCAN_SUFFIXES:
files.append(absolute_root)
continue
for path in absolute_root.rglob("*"):
if path.is_file() and path.suffix in SCAN_SUFFIXES:
files.append(path)
return sorted(files)
def sanitize_excerpt(line: str) -> str:
excerpt = line.strip()
excerpt = SECRET_INTERPOLATION_RE.sub("${{ secrets.<redacted> }}", excerpt)
excerpt = BOT_TOKEN_URL_RE.sub(
lambda match: f"api.telegram.org/bot<redacted>/{match.group('method')}",
excerpt,
)
excerpt = BOT_TOKEN_FRAGMENT_RE.sub(
lambda match: f"bot<redacted>/{match.group('method')}",
excerpt,
)
return excerpt[:180]
def _compact_source(source: str) -> str:
without_string_prefixes = re.sub(
r"(?i)\b(?:fr|rf|f|r|u|b)(?=[\"'])",
"",
source,
)
return re.sub(r"[\s\"'`+()]+", "", without_string_prefixes)
_PYTHON_HTTP_TRANSPORT_CALL_RE = re.compile(
r"^(?:"
r"requests\.(?:post|request)|"
r"requests\.Session\(\)\.(?:post|request|send)|"
r"httpx\.(?:post|request)|"
r"httpx\.(?:AsyncClient|Client)\(\)\.(?:post|request|send)|"
r"urllib\.request\.(?:urlopen|Request)|"
r"urllib\.request\.build_opener\(\)\.open"
r")$"
)
def _record_python_import_alias(
node: ast.AST,
aliases: dict[str, str],
) -> None:
if isinstance(node, ast.Import):
for imported in node.names:
bound_name = imported.asname or imported.name.split(".", 1)[0]
aliases[bound_name] = (
imported.name if imported.asname else bound_name
)
return
module = str(node.module or "").strip(".")
if not module:
return
for imported in node.names:
if imported.name == "*":
continue
aliases[imported.asname or imported.name] = (
f"{module}.{imported.name}"
)
def _resolve_python_expression(
node: ast.AST,
aliases: dict[str, str],
) -> str | None:
if isinstance(node, ast.Name):
return aliases.get(node.id, node.id)
if isinstance(node, ast.Attribute):
owner = _resolve_python_expression(node.value, aliases)
return f"{owner}.{node.attr}" if owner else None
if isinstance(node, ast.Call):
callable_name = _resolve_python_expression(node.func, aliases)
return f"{callable_name}()" if callable_name else None
return None
def _assignment_target_names(node: ast.AST) -> list[str]:
if isinstance(node, ast.Name):
return [node.id]
if isinstance(node, _AST_SEQUENCE_TARGET_NODE_TYPES):
return [
name
for child in node.elts
for name in _assignment_target_names(child)
]
return []
def _python_parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]:
return {
child: parent
for parent in ast.walk(tree)
for child in ast.iter_child_nodes(parent)
}
def _python_function_qualified_name(
function: ast.FunctionDef | ast.AsyncFunctionDef,
parents: dict[ast.AST, ast.AST],
) -> str:
parts = [function.name]
parent = parents.get(function)
while parent is not None:
if isinstance(parent, (ast.ClassDef, *_AST_FUNCTION_NODE_TYPES)):
parts.append(parent.name)
parent = parents.get(parent)
return ".".join(reversed(parts))
def _nearest_python_function(
node: ast.AST,
parents: dict[ast.AST, ast.AST],
) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
parent = parents.get(node)
while parent is not None:
if isinstance(parent, _AST_FUNCTION_NODE_TYPES):
return parent
parent = parents.get(parent)
return None
def _is_python_module_scope(
node: ast.AST,
parents: dict[ast.AST, ast.AST],
) -> bool:
parent = parents.get(node)
while parent is not None:
if isinstance(parent, _AST_NON_MODULE_SCOPE_NODE_TYPES):
return False
parent = parents.get(parent)
return True
def _resolve_python_transport_calls(
nodes: list[ast.AST],
aliases: dict[str, str],
) -> list[tuple[int, str]]:
resolved_calls: list[tuple[int, str]] = []
for node in sorted(
nodes,
key=lambda item: (
int(getattr(item, "lineno", 0)),
int(getattr(item, "col_offset", 0)),
0 if isinstance(item, _AST_IMPORT_NODE_TYPES) else 1,
),
):
if isinstance(node, _AST_IMPORT_NODE_TYPES):
_record_python_import_alias(node, aliases)
continue
if isinstance(node, ast.Assign):
resolved_value = _resolve_python_expression(node.value, aliases)
for target in node.targets:
for name in _assignment_target_names(target):
if resolved_value:
aliases[name] = resolved_value
else:
aliases.pop(name, None)
continue
if isinstance(node, ast.AnnAssign):
resolved_value = (
_resolve_python_expression(node.value, aliases)
if node.value is not None
else None
)
for name in _assignment_target_names(node.target):
if resolved_value:
aliases[name] = resolved_value
else:
aliases.pop(name, None)
continue
if not isinstance(node, ast.Call):
continue
qualified_call = _resolve_python_expression(node.func, aliases)
if qualified_call and _PYTHON_HTTP_TRANSPORT_CALL_RE.fullmatch(
qualified_call
):
resolved_calls.append((node.lineno, qualified_call))
return sorted(set(resolved_calls))
def _python_transport_calls_by_scope(
text: str,
) -> tuple[
dict[tuple[str, int, int], list[tuple[int, str]]],
list[tuple[int, str]],
]:
"""Resolve HTTP aliases in function and true module-level AST scopes."""
try:
tree = ast.parse(text)
except SyntaxError:
return {}, []
parents = _python_parent_map(tree)
module_aliases: dict[str, str] = {}
module_nodes = [
node
for node in ast.walk(tree)
if _is_python_module_scope(node, parents)
]
module_calls = _resolve_python_transport_calls(
module_nodes,
module_aliases,
)
transports: dict[tuple[str, int, int], list[tuple[int, str]]] = {}
function_nodes = [
node
for node in ast.walk(tree)
if isinstance(node, _AST_FUNCTION_NODE_TYPES)
and node.end_lineno is not None
]
for function in function_nodes:
aliases = dict(module_aliases)
arguments = [
*function.args.posonlyargs,
*function.args.args,
*function.args.kwonlyargs,
]
if function.args.vararg is not None:
arguments.append(function.args.vararg)
if function.args.kwarg is not None:
arguments.append(function.args.kwarg)
for argument in arguments:
aliases.pop(argument.arg, None)
scoped_nodes = [
node
for node in ast.walk(function)
if node is function
or _nearest_python_function(node, parents) is function
]
resolved_calls = _resolve_python_transport_calls(scoped_nodes, aliases)
if resolved_calls:
qualified_name = _python_function_qualified_name(function, parents)
transports[
(
qualified_name,
function.lineno,
int(function.end_lineno),
)
] = resolved_calls
return transports, module_calls
def _transport_window_units(
text: str,
*,
excluded_line_ranges: list[tuple[int, int]],
) -> list[tuple[str, str, int, int, str]]:
lines = text.splitlines()
units: list[tuple[str, str, int, int, str]] = []
for match in DIRECT_HTTP_TRANSPORT_RE.finditer(text):
line_number = text.count("\n", 0, match.start()) + 1
if any(start <= line_number <= end for start, end in excluded_line_ranges):
continue
start_line = max(1, line_number - 20)
end_line = min(len(lines), line_number + 20)
units.append(
(
"<module>",
"<module>",
start_line,
end_line,
"\n".join(lines[start_line - 1 : end_line]),
)
)
return units
def _source_units(
relative_path: str,
text: str,
) -> list[tuple[str, str, int, int, str]]:
lines = text.splitlines()
units: list[tuple[str, str, int, int, str]] = []
ranges: list[tuple[int, int]] = []
if relative_path.endswith(".py"):
try:
tree = ast.parse(text)
except SyntaxError:
tree = None
if tree is not None:
parents = _python_parent_map(tree)
nodes = [
node
for node in ast.walk(tree)
if isinstance(node, _AST_FUNCTION_NODE_TYPES)
and getattr(node, "end_lineno", None)
]
for node in sorted(
nodes, key=lambda item: (item.lineno, item.end_lineno or item.lineno)
):
end_line = int(node.end_lineno or node.lineno)
ranges.append((node.lineno, end_line))
units.append(
(
node.name,
_python_function_qualified_name(node, parents),
node.lineno,
end_line,
"\n".join(lines[node.lineno - 1 : end_line]),
)
)
elif relative_path.endswith(".ps1"):
matches = list(POWERSHELL_FUNCTION_RE.finditer(text))
for index, match in enumerate(matches):
start_line = text.count("\n", 0, match.start()) + 1
end_offset = (
matches[index + 1].start() if index + 1 < len(matches) else len(text)
)
end_line = text.count("\n", 0, end_offset) + 1
ranges.append((start_line, end_line))
units.append(
(
match.group("name"),
match.group("name"),
start_line,
end_line,
text[match.start() : end_offset],
)
)
return units + _transport_window_units(text, excluded_line_ranges=ranges)
def scan_direct_bot_api_surfaces(
relative_path: str,
text: str,
) -> list[dict[str, Any]]:
"""Find direct Telegram transports, including split URLs and custom senders."""
source_lines = text.splitlines()
findings: list[dict[str, Any]] = []
seen: set[tuple[int, str, str]] = set()
python_transports, module_transports = (
_python_transport_calls_by_scope(text)
if relative_path.endswith(".py")
else ({}, [])
)
source_units = _source_units(relative_path, text)
for line_number, _qualified_call in module_transports:
start_line = max(1, line_number - 20)
end_line = min(len(source_lines), line_number + 20)
source_units.append(
(
"<module>",
"<module>",
start_line,
end_line,
"\n".join(source_lines[start_line - 1 : end_line]),
)
)
for (
function_name,
function_qualified,
start_line,
_end_line,
source,
) in source_units:
if (relative_path, function_qualified) in CANONICAL_FINAL_EXITS:
continue
resolved_python_transports = (
[
item
for item in module_transports
if start_line <= item[0] <= _end_line
]
if function_qualified == "<module>"
else python_transports.get(
(function_qualified, start_line, _end_line),
[],
)
)
transport_match = DIRECT_HTTP_TRANSPORT_RE.search(source)
if transport_match is None and not resolved_python_transports:
continue
method_match = GUARDED_BOT_METHOD_RE.search(source)
endpoint_match = COMPACT_BOT_ENDPOINT_RE.search(_compact_source(source))
telegram_named = bool(
re.search(r"(?:telegram|bot)", function_name, re.IGNORECASE)
)
telegram_context = bool(TELEGRAM_CONTEXT_RE.search(source))
token_and_chat_context = bool(
BOT_TOKEN_CONTEXT_RE.search(source) and CHAT_CONTEXT_RE.search(source)
)
if (
endpoint_match is None
and method_match is None
and not ((telegram_named and telegram_context) or token_and_chat_context)
):
continue
method = (
endpoint_match.group("method")
if endpoint_match is not None
else method_match.group("method")
if method_match is not None
else "dynamic"
)
line_number = (
resolved_python_transports[0][0]
if resolved_python_transports
else start_line + source[: transport_match.start()].count("\n")
)
detection_kind = (
"direct_bot_api_endpoint"
if endpoint_match is not None
else "custom_direct_sender"
)
key = (line_number, method.lower(), detection_kind)
if key in seen:
continue
seen.add(key)
source_line = (
source_lines[line_number - 1] if line_number <= len(source_lines) else ""
)
findings.append(
{
"line": line_number,
"method": method,
"detection_kind": detection_kind,
"function": function_name,
"function_qualified": function_qualified,
"sanitized_excerpt": sanitize_excerpt(source_line),
}
)
return findings
def surface_kind(relative_path: str) -> str:
if relative_path.startswith(".github/workflows/"):
return "github_frozen_legacy_workflow_direct_bot_api"
if relative_path.startswith(".gitea/workflows/"):
return "gitea_workflow_direct_bot_api"
if relative_path.startswith("scripts/ops/"):
return "ops_script_direct_bot_api"
if relative_path.startswith("scripts/ci/"):
return "ci_script_direct_bot_api"
if relative_path == "agent99-control-plane.ps1":
return "agent99_control_plane_direct_bot_api"
if relative_path.startswith("scripts/reboot-recovery/"):
return "reboot_recovery_direct_bot_api"
if relative_path.startswith("apps/api/src/"):
return "api_direct_bot_api"
return "other_direct_bot_api"
def source_truth_classification(relative_path: str) -> str:
if relative_path.startswith(".github/workflows/"):
return "frozen_legacy_source_truth"
return "active_repo_source_truth"
def line_hash(relative_path: str, line_number: int, line: str) -> str:
payload = f"{relative_path}:{line_number}:{line.strip()}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:16]
def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
generated = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
files = iter_scannable_files(root)
direct_calls: list[dict[str, Any]] = []
gateway_calls: list[dict[str, Any]] = []
for path in files:
relative_path = path.relative_to(root).as_posix()
text = path.read_text(encoding="utf-8", errors="replace")
text_lines = text.splitlines()
for finding in scan_direct_bot_api_surfaces(relative_path, text):
line_number = int(finding["line"])
line = text_lines[line_number - 1] if line_number <= len(text_lines) else ""
kind = surface_kind(relative_path)
truth_classification = source_truth_classification(relative_path)
direct_calls.append(
{
"egress_surface_id": f"telegram_egress:{kind}:{relative_path}:{line_number}",
"surface_kind": kind,
"source_truth_classification": truth_classification,
"path": relative_path,
"line": line_number,
"line_hash": line_hash(relative_path, line_number, line),
"method": finding["method"],
"detection_kind": finding["detection_kind"],
"function": finding["function"],
"sanitized_excerpt": finding["sanitized_excerpt"],
"required_owner_fields": REQUIRED_OWNER_FIELDS,
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"owner_response_received": False,
"owner_response_accepted": False,
"formatter_convergence_accepted": False,
"redaction_contract_accepted": False,
"delivery_receipt_accepted": False,
"direct_bot_api_migration_authorized": False,
"telegram_send_authorized": False,
"bot_api_call_authorized": False,
"workflow_modification_authorized": False,
"script_modification_authorized": False,
"secret_value_collection_allowed": False,
"raw_payload_storage_allowed": False,
"production_write_authorized": False,
"runtime_execution_authorized": False,
"workflow_execution_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
"not_authorization": True,
}
)
for line_number, line in enumerate(text_lines, start=1):
if GATEWAY_CALLSITE_RE.search(line):
gateway_calls.append(
{
"path": relative_path,
"line": line_number,
"line_hash": line_hash(relative_path, line_number, line),
}
)
active_direct_calls = [
item
for item in direct_calls
if item["source_truth_classification"] == "active_repo_source_truth"
]
frozen_legacy_direct_calls = [
item
for item in direct_calls
if item["source_truth_classification"] == "frozen_legacy_source_truth"
]
direct_files = sorted({item["path"] for item in direct_calls})
active_direct_files = sorted({item["path"] for item in active_direct_calls})
frozen_legacy_direct_files = sorted(
{item["path"] for item in frozen_legacy_direct_calls}
)
workflow_direct_calls = [
item
for item in direct_calls
if item["surface_kind"] == "gitea_workflow_direct_bot_api"
]
ops_direct_calls = [
item
for item in direct_calls
if item["surface_kind"] == "ops_script_direct_bot_api"
]
ci_direct_calls = [
item
for item in direct_calls
if item["surface_kind"] == "ci_script_direct_bot_api"
]
api_direct_calls = [
item for item in direct_calls if item["surface_kind"] == "api_direct_bot_api"
]
custom_direct_senders = [
item
for item in direct_calls
if item["detection_kind"] == "custom_direct_sender"
]
direct_endpoints = [
item
for item in direct_calls
if item["detection_kind"] == "direct_bot_api_endpoint"
]
telegram_gateway_path = root / "apps/api/src/services/telegram_gateway.py"
telegram_gateway_text = telegram_gateway_path.read_text(
encoding="utf-8", errors="replace"
)
gateway_formatter_present = (
"normalize_telegram_send_message_payload" in telegram_gateway_text
)
return {
"schema_version": "telegram_notification_egress_inventory_v1",
"generated_at": generated,
"git_commit": git_short_sha(root),
"status": "inventory_ready_no_runtime_action",
"mode": "repo_only_scan_no_secret_value_no_telegram_send",
"scan_roots": [path.as_posix() for path in SCAN_ROOTS],
"summary": {
"scanned_file_count": len(files),
"direct_bot_api_file_count": len(direct_files),
"direct_bot_api_call_count": len(direct_calls),
"active_direct_bot_api_file_count": len(active_direct_files),
"active_direct_bot_api_call_count": len(active_direct_calls),
"github_frozen_legacy_direct_bot_api_file_count": len(
frozen_legacy_direct_files
),
"github_frozen_legacy_direct_bot_api_call_count": len(
frozen_legacy_direct_calls
),
"direct_bot_api_endpoint_count": len(direct_endpoints),
"custom_direct_sender_count": len(custom_direct_senders),
"workflow_direct_bot_api_call_count": len(workflow_direct_calls),
"ops_script_direct_bot_api_call_count": len(ops_direct_calls),
"ci_script_direct_bot_api_call_count": len(ci_direct_calls),
"api_direct_bot_api_call_count": len(api_direct_calls),
"gateway_normalized_callsite_count": len(gateway_calls),
"gateway_final_exit_formatter_present_count": 1
if gateway_formatter_present
else 0,
"required_owner_field_count": len(REQUIRED_OWNER_FIELDS),
"reviewer_check_count": len(REVIEWER_CHECKS),
"outcome_lane_count": len(OUTCOME_LANES),
"blocked_action_count": len(BLOCKED_ACTIONS),
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"formatter_convergence_accepted_count": 0,
"redaction_contract_accepted_count": 0,
"delivery_receipt_accepted_count": 0,
"direct_bot_api_migration_authorized_count": 0,
"telegram_send_authorized_count": 0,
"bot_api_call_authorized_count": 0,
"workflow_modification_authorized_count": 0,
"workflow_execution_authorized_count": 0,
"script_modification_authorized_count": 0,
"secret_value_collection_allowed_count": 0,
"raw_payload_storage_allowed_count": 0,
"production_write_authorized_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
},
"execution_boundaries": {
"runtime_execution_authorized": False,
"telegram_send_authorized": False,
"bot_api_call_authorized": False,
"workflow_modification_authorized": False,
"workflow_execution_authorized": False,
"github_workflow_execution_authorized": False,
"script_modification_authorized": False,
"secret_value_collection_allowed": False,
"secret_hash_collection_allowed": False,
"partial_token_collection_allowed": False,
"raw_payload_storage_allowed": False,
"chat_route_change_authorized": False,
"bot_token_change_authorized": False,
"workflow_dispatch_authorized": False,
"production_deploy_authorized": False,
"action_buttons_allowed": False,
"not_authorization": True,
},
"direct_bot_api_calls": direct_calls,
"gateway_normalized_callsite_refs": gateway_calls,
"operator_interpretation": [
"active_direct_bot_api_call_count 大於 0 代表仍有 active workflow / ops / API 旁路可能繞過 TelegramGateway formatter。",
".github/workflows 僅列為 frozen_legacy_source_truth 供盤點runtime_execution_authorized=false、workflow_execution_authorized=false禁止執行。",
"本清冊只建立 metadata-only egress surface不送 Telegram、不修改 workflow / script、不讀 secret value。",
"後續要收斂 direct Bot API 必須另走 owner response、formatter convergence、redaction contract、delivery receipt 與維護窗口。",
],
}
def validate(root: Path) -> None:
report = build_report(root)
if report["summary"]["gateway_final_exit_formatter_present_count"] != 1:
raise SystemExit(
"BLOCKED telegram egress inventory: gateway formatter not found"
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Build Telegram notification egress inventory"
)
parser.add_argument("--root", default=".", help="repository root")
parser.add_argument("--output", help="write JSON snapshot")
parser.add_argument("--generated-at", help="fixed generated_at timestamp")
args = parser.parse_args()
root = Path(args.root).resolve()
report = build_report(root, args.generated_at)
payload = json.dumps(report, ensure_ascii=False, indent=2) + "\n"
if args.output:
Path(args.output).write_text(payload, encoding="utf-8")
else:
sys.stdout.write(payload)
print(
"TELEGRAM_NOTIFICATION_EGRESS_INVENTORY_OK "
f"direct_calls={report['summary']['direct_bot_api_call_count']} "
f"active={report['summary']['active_direct_bot_api_call_count']} "
f"frozen_legacy={report['summary']['github_frozen_legacy_direct_bot_api_call_count']} "
f"custom={report['summary']['custom_direct_sender_count']} "
f"files={report['summary']['direct_bot_api_file_count']} "
f"workflow={report['summary']['workflow_direct_bot_api_call_count']} "
f"ops={report['summary']['ops_script_direct_bot_api_call_count']} "
f"api={report['summary']['api_direct_bot_api_call_count']} "
f"runtime_gate={report['summary']['runtime_gate_count']}",
file=sys.stderr,
)
if __name__ == "__main__":
main()