850 lines
29 KiB
Python
850 lines
29 KiB
Python
#!/usr/bin/env python3
|
||
"""檢查 Telegram 通知出口不可新增未登記 direct Bot API 旁路。
|
||
|
||
本 guard 只掃描 repo 原始碼與 committed snapshot,不讀 secret、不呼叫
|
||
Telegram、不修改或執行 workflow / script / API sender。Committed baseline
|
||
僅供 drift 比對;目前 active source 的 direct endpoint 或 custom sender 必須
|
||
真正為零。Local ``.github/workflows`` 只以 frozen legacy source truth 稽核,
|
||
不得以 regex 漏掃、歷史基線或 frozen source 產生 false-green。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import ast
|
||
import json
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
from collections import Counter
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
TAIPEI = timezone(timedelta(hours=8))
|
||
|
||
SOURCE_SNAPSHOT = Path(
|
||
"docs/security/telegram-notification-egress-inventory.snapshot.json"
|
||
)
|
||
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_ENDPOINT_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,
|
||
)
|
||
SECRET_INTERPOLATION_RE = re.compile(r"\$\{\{\s*secrets\.[^}]+\}\}")
|
||
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,
|
||
)
|
||
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,
|
||
)
|
||
|
||
|
||
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 signature(path: str, method: str, sanitized_excerpt: str) -> str:
|
||
return f"{path}::{method.lower()}::{sanitized_excerpt}"
|
||
|
||
|
||
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 load_source_snapshot(root: Path) -> dict[str, Any]:
|
||
snapshot_path = root / SOURCE_SNAPSHOT
|
||
return json.loads(snapshot_path.read_text(encoding="utf-8"))
|
||
|
||
|
||
def build_baseline(source_snapshot: dict[str, Any]) -> Counter[str]:
|
||
baseline: Counter[str] = Counter()
|
||
for item in source_snapshot.get("direct_bot_api_calls", []):
|
||
if (
|
||
source_truth_classification(str(item["path"]))
|
||
== "frozen_legacy_source_truth"
|
||
):
|
||
continue
|
||
excerpt = item.get("sanitized_excerpt", "")
|
||
match = BOT_ENDPOINT_RE.search(excerpt)
|
||
method = str(
|
||
item.get("method") or (match.group("method") if match else "dynamic")
|
||
)
|
||
baseline[signature(item["path"], method, excerpt)] += 1
|
||
return baseline
|
||
|
||
|
||
def scan_current_direct_endpoints(root: Path) -> list[dict[str, Any]]:
|
||
findings: list[dict[str, Any]] = []
|
||
for path in iter_scannable_files(root):
|
||
relative_path = path.relative_to(root).as_posix()
|
||
text = path.read_text(encoding="utf-8", errors="replace")
|
||
for finding in scan_direct_bot_api_surfaces(relative_path, text):
|
||
method = finding["method"]
|
||
sanitized = finding["sanitized_excerpt"]
|
||
findings.append(
|
||
{
|
||
"path": relative_path,
|
||
"line": finding["line"],
|
||
"method": method,
|
||
"detection_kind": finding["detection_kind"],
|
||
"function": finding["function"],
|
||
"sanitized_excerpt": sanitized,
|
||
"signature": signature(relative_path, method, sanitized),
|
||
"source_truth_classification": source_truth_classification(
|
||
relative_path
|
||
),
|
||
"runtime_execution_authorized": False,
|
||
"workflow_execution_authorized": False,
|
||
}
|
||
)
|
||
return findings
|
||
|
||
|
||
def method_counts(findings: list[dict[str, Any]]) -> dict[str, int]:
|
||
counts = {method: 0 for method in GUARDED_BOT_METHODS}
|
||
for item in findings:
|
||
for method in GUARDED_BOT_METHODS:
|
||
if item["method"].lower() == method.lower():
|
||
counts[method] += 1
|
||
break
|
||
return counts
|
||
|
||
|
||
def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]:
|
||
generated = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
|
||
source_snapshot = load_source_snapshot(root)
|
||
baseline = build_baseline(source_snapshot)
|
||
detected_findings = scan_current_direct_endpoints(root)
|
||
current_findings = [
|
||
item
|
||
for item in detected_findings
|
||
if item["source_truth_classification"] == "active_repo_source_truth"
|
||
]
|
||
frozen_legacy_findings = [
|
||
item
|
||
for item in detected_findings
|
||
if item["source_truth_classification"] == "frozen_legacy_source_truth"
|
||
]
|
||
remaining_baseline = baseline.copy()
|
||
new_bypass_findings: list[dict[str, Any]] = []
|
||
|
||
for item in current_findings:
|
||
item_signature = item["signature"]
|
||
if remaining_baseline[item_signature] > 0:
|
||
remaining_baseline[item_signature] -= 1
|
||
continue
|
||
new_bypass_findings.append(item)
|
||
|
||
removed_baseline_signatures = [
|
||
{"signature": item_signature, "removed_count": count}
|
||
for item_signature, count in sorted(remaining_baseline.items())
|
||
if count > 0
|
||
]
|
||
current_files = sorted({item["path"] for item in current_findings})
|
||
new_bypass_files = sorted({item["path"] for item in new_bypass_findings})
|
||
counts_by_method = method_counts(current_findings)
|
||
custom_direct_senders = [
|
||
item
|
||
for item in current_findings
|
||
if item["detection_kind"] == "custom_direct_sender"
|
||
]
|
||
direct_endpoints = [
|
||
item
|
||
for item in current_findings
|
||
if item["detection_kind"] == "direct_bot_api_endpoint"
|
||
]
|
||
source_summary = source_snapshot["summary"]
|
||
|
||
return {
|
||
"schema_version": "telegram_notification_egress_no_new_bypass_guard_v1",
|
||
"generated_at": generated,
|
||
"git_commit": git_short_sha(root),
|
||
"status": "pass_no_direct_or_custom_bypass"
|
||
if not current_findings
|
||
else "blocked_direct_or_custom_bypass_detected",
|
||
"mode": "repo_source_scan_no_secret_value_no_telegram_send",
|
||
"source_snapshot": SOURCE_SNAPSHOT.as_posix(),
|
||
"guarded_roots": [path.as_posix() for path in SCAN_ROOTS],
|
||
"guarded_bot_methods": list(GUARDED_BOT_METHODS),
|
||
"summary": {
|
||
"source_direct_bot_api_call_count": source_summary[
|
||
"direct_bot_api_call_count"
|
||
],
|
||
"source_direct_bot_api_file_count": source_summary[
|
||
"direct_bot_api_file_count"
|
||
],
|
||
"baseline_signature_count": sum(baseline.values()),
|
||
"detected_direct_bot_api_call_count": len(detected_findings),
|
||
"current_direct_bot_api_call_count": len(current_findings),
|
||
"current_direct_bot_api_file_count": len(current_files),
|
||
"current_direct_bot_api_endpoint_count": len(direct_endpoints),
|
||
"current_custom_direct_sender_count": len(custom_direct_senders),
|
||
"github_frozen_legacy_direct_bot_api_call_count": len(
|
||
frozen_legacy_findings
|
||
),
|
||
"github_frozen_legacy_direct_bot_api_file_count": len(
|
||
{item["path"] for item in frozen_legacy_findings}
|
||
),
|
||
"guarded_method_count": len(GUARDED_BOT_METHODS),
|
||
"sendMessage_call_count": counts_by_method["sendMessage"],
|
||
"sendDocument_call_count": counts_by_method["sendDocument"],
|
||
"sendPhoto_call_count": counts_by_method["sendPhoto"],
|
||
"sendMediaGroup_call_count": counts_by_method["sendMediaGroup"],
|
||
"editMessageText_call_count": counts_by_method["editMessageText"],
|
||
"other_guarded_method_call_count": sum(
|
||
count
|
||
for method, count in counts_by_method.items()
|
||
if method
|
||
not in {
|
||
"sendMessage",
|
||
"sendDocument",
|
||
"sendPhoto",
|
||
"sendMediaGroup",
|
||
"editMessageText",
|
||
}
|
||
),
|
||
"new_bypass_count": len(new_bypass_findings),
|
||
"new_bypass_file_count": len(new_bypass_files),
|
||
"removed_baseline_call_count": sum(
|
||
item["removed_count"] for item in removed_baseline_signatures
|
||
),
|
||
"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,
|
||
"api_sender_refactor_authorized": False,
|
||
"secret_value_collection_allowed": False,
|
||
"secret_hash_collection_allowed": False,
|
||
"partial_token_collection_allowed": False,
|
||
"chat_route_change_authorized": False,
|
||
"bot_token_change_authorized": False,
|
||
"raw_payload_storage_allowed": False,
|
||
"production_write_authorized": False,
|
||
"action_buttons_allowed": False,
|
||
"not_authorization": True,
|
||
},
|
||
"current_direct_bot_api_calls": current_findings,
|
||
"detected_direct_bot_api_calls": detected_findings,
|
||
"frozen_legacy_direct_bot_api_calls": frozen_legacy_findings,
|
||
"new_bypass_findings": new_bypass_findings,
|
||
"removed_baseline_signatures": removed_baseline_signatures,
|
||
"operator_interpretation": [
|
||
"current_direct_bot_api_call_count 必須為 0,才代表沒有 Telegram direct/custom bypass。",
|
||
".github/workflows 僅以 frozen_legacy_source_truth 盤點,不計入 active/new failure;runtime 與 workflow execution 均未授權且禁止執行。",
|
||
(
|
||
f"committed inventory 目前有 {source_summary['direct_bot_api_call_count']} 個 direct Bot API call site;"
|
||
"baseline 只做 drift 比對,不能批准或隱藏目前 source 旁路。"
|
||
),
|
||
"分段 URL、requests/httpx/urllib、Invoke-WebRequest/Invoke-RestMethod 與自訂 sender 都會被掃描。",
|
||
"本 guard 只讀 repo source 與 committed snapshot,不送 Telegram、不讀 Bot token、不修改 workflow / script / API sender。",
|
||
],
|
||
}
|
||
|
||
|
||
def validate(root: Path) -> None:
|
||
report = build_report(root)
|
||
errors: list[str] = []
|
||
if report["summary"]["current_direct_bot_api_call_count"]:
|
||
for item in report["current_direct_bot_api_calls"]:
|
||
errors.append(
|
||
f"{item['path']}:{item['line']}: Telegram direct/custom bypass "
|
||
f"{item['detection_kind']} {item['method']} ({item['function']})"
|
||
)
|
||
|
||
if errors:
|
||
raise SystemExit(
|
||
"BLOCKED telegram notification egress no-new-bypass guard:\n"
|
||
+ "\n".join(f"- {error}" for error in errors)
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(
|
||
description="檢查 Telegram 通知出口不可新增未登記 direct Bot API 旁路"
|
||
)
|
||
parser.add_argument("--root", default=".", help="repository root")
|
||
parser.add_argument("--output", help="寫出 JSON 報告")
|
||
parser.add_argument(
|
||
"--generated-at", help="固定 generated_at 時間,供 committed snapshot 使用"
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
root = Path(args.root).resolve()
|
||
report = build_report(root, args.generated_at)
|
||
if args.output:
|
||
output = Path(args.output)
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
output.write_text(
|
||
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
validate(root)
|
||
summary = report["summary"]
|
||
print(
|
||
"TELEGRAM_NOTIFICATION_EGRESS_NO_NEW_BYPASS_GUARD_OK "
|
||
f"current={summary['current_direct_bot_api_call_count']} "
|
||
f"custom={summary['current_custom_direct_sender_count']} "
|
||
f"frozen_legacy={summary['github_frozen_legacy_direct_bot_api_call_count']} "
|
||
f"baseline={summary['baseline_signature_count']} "
|
||
f"new={summary['new_bypass_count']} "
|
||
f"sendDocument={summary['sendDocument_call_count']} "
|
||
f"runtime_gate={summary['runtime_gate_count']}"
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|