Files
awoooi/apps/api/tests/test_telegram_notification_egress_scanners.py

496 lines
14 KiB
Python

from __future__ import annotations
import importlib.util
import json
from pathlib import Path
from types import ModuleType
import pytest
ROOT = Path(__file__).resolve().parents[3]
def _load_scanner(filename: str, module_name: str) -> ModuleType:
path = ROOT / "scripts/security" / filename
spec = importlib.util.spec_from_file_location(module_name, path)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
INVENTORY = _load_scanner(
"telegram-notification-egress-inventory.py",
"telegram_notification_egress_inventory_test",
)
NO_NEW_BYPASS_GUARD = _load_scanner(
"telegram-notification-egress-no-new-bypass-guard.py",
"telegram_notification_egress_no_new_bypass_guard_test",
)
SCANNERS = (INVENTORY, NO_NEW_BYPASS_GUARD)
def _write_zero_baseline(root: Path) -> None:
snapshot = (
root / "docs/security/telegram-notification-egress-inventory.snapshot.json"
)
snapshot.parent.mkdir(parents=True, exist_ok=True)
snapshot.write_text(
json.dumps(
{
"summary": {
"direct_bot_api_call_count": 0,
"direct_bot_api_file_count": 0,
},
"direct_bot_api_calls": [],
}
),
encoding="utf-8",
)
def _write_minimal_gateway(root: Path) -> None:
gateway = root / "apps/api/src/services/telegram_gateway.py"
gateway.parent.mkdir(parents=True, exist_ok=True)
gateway.write_text(
"normalize_telegram_send_message_payload = object()\n",
encoding="utf-8",
)
@pytest.mark.parametrize("scanner", SCANNERS)
def test_scanner_detects_multiline_split_bot_api_url(scanner: ModuleType) -> None:
source = """
import requests
def send_telegram_alert(token: str) -> object:
base = "https://api.telegram.org/"
endpoint = (
base
+ f"bot{token}/"
+ "sendMessage"
)
return requests.post(endpoint, json={"text": "bounded"})
"""
findings = scanner.scan_direct_bot_api_surfaces("scripts/ops/split.py", source)
assert len(findings) == 1
assert findings[0]["method"] == "sendMessage"
assert findings[0]["detection_kind"] == "direct_bot_api_endpoint"
@pytest.mark.parametrize("scanner", SCANNERS)
@pytest.mark.parametrize("transport", ["Invoke-WebRequest", "Invoke-RestMethod"])
def test_scanner_detects_powershell_direct_transport(
scanner: ModuleType,
transport: str,
) -> None:
source = f"""
function Send-AgentTelegramLegacy {{
$base = "https://api.telegram.org/"
$uri = $base + "bot" + $Token + "/sendPhoto"
{transport} -Method Post -Uri $uri
}}
"""
findings = scanner.scan_direct_bot_api_surfaces(
"agent99-control-plane.ps1",
source,
)
assert len(findings) == 1
assert findings[0]["method"] == "sendPhoto"
assert findings[0]["detection_kind"] == "direct_bot_api_endpoint"
@pytest.mark.parametrize("scanner", SCANNERS)
@pytest.mark.parametrize(
"transport",
[
"requests.post(target, json=payload)",
"urllib.request.urlopen(target, data=payload)",
"client.PostAsync(target, content)",
],
)
def test_scanner_detects_custom_sender_without_literal_endpoint(
scanner: ModuleType,
transport: str,
) -> None:
source = f"""
function Send-AgentTelegramLegacy {{
$target = $ConfiguredProviderUrl + "/sendMessage"
{transport}
}}
"""
findings = scanner.scan_direct_bot_api_surfaces(
"agent99-control-plane.ps1",
source,
)
assert len(findings) == 1
assert findings[0]["method"] == "sendMessage"
assert findings[0]["detection_kind"] == "custom_direct_sender"
@pytest.mark.parametrize("scanner", SCANNERS)
@pytest.mark.parametrize(
"transport",
[
"httpx.Client().post(endpoint, json=payload)",
"requests.Session().post(endpoint, json=payload)",
"urllib.request.build_opener().open(endpoint, data=payload)",
"opener.open(endpoint, data=payload)",
],
)
def test_scanner_detects_factory_clients_and_urllib_openers_with_dynamic_endpoint(
scanner: ModuleType,
transport: str,
) -> None:
source = f"""
def dispatch(endpoint: str, bot_token: str, chat_id: str, payload: bytes) -> object:
return {transport}
"""
findings = scanner.scan_direct_bot_api_surfaces(
"scripts/ops/provider_dispatch.py",
source,
)
assert len(findings) == 1
assert findings[0]["method"] == "dynamic"
assert findings[0]["detection_kind"] == "custom_direct_sender"
assert findings[0]["function"] == "dispatch"
@pytest.mark.parametrize("scanner", SCANNERS)
@pytest.mark.parametrize(
("imports", "transport"),
[
("import requests as rq", "rq.post(endpoint, json=payload)"),
("import httpx as hx", "hx.post(endpoint, json=payload)"),
(
"import httpx as hx",
"hx.Client().post(endpoint, json=payload)",
),
(
"from httpx import Client as HC",
"HC().post(endpoint, json=payload)",
),
(
"from requests import Session as S",
"S().post(endpoint, json=payload)",
),
(
"from urllib.request import build_opener as make_opener",
"make_opener().open(endpoint, data=payload)",
),
],
)
def test_scanner_resolves_python_import_alias_transport_calls(
scanner: ModuleType,
imports: str,
transport: str,
) -> None:
source = f"""
{imports}
def dispatch(endpoint: str, bot_token: str, chat_id: str, payload: bytes) -> object:
return {transport}
"""
findings = scanner.scan_direct_bot_api_surfaces(
"scripts/ops/aliased_provider_dispatch.py",
source,
)
assert len(findings) == 1
assert findings[0]["method"] == "dynamic"
assert findings[0]["detection_kind"] == "custom_direct_sender"
assert findings[0]["function"] == "dispatch"
@pytest.mark.parametrize("scanner", SCANNERS)
@pytest.mark.parametrize(
("imports", "transport"),
[
("import requests as rq", "rq.post(endpoint, json=payload)"),
(
"import httpx as hx",
"hx.Client().post(endpoint, json=payload)",
),
(
"from httpx import Client as HC",
"HC().post(endpoint, json=payload)",
),
(
"from requests import Session as S",
"S().post(endpoint, json=payload)",
),
(
"from urllib.request import build_opener as make_opener",
"make_opener().open(endpoint, data=payload)",
),
],
)
def test_scanner_resolves_module_scope_import_alias_transport_calls(
scanner: ModuleType,
imports: str,
transport: str,
) -> None:
source = f"""
{imports}
bot_token = object()
chat_id = object()
endpoint = object()
payload = b""
result = {transport}
"""
findings = scanner.scan_direct_bot_api_surfaces(
"scripts/ops/module_alias_provider.py",
source,
)
assert len(findings) == 1
assert findings[0]["method"] == "dynamic"
assert findings[0]["detection_kind"] == "custom_direct_sender"
assert findings[0]["function"] == "<module>"
assert findings[0]["function_qualified"] == "<module>"
@pytest.mark.parametrize("scanner", SCANNERS)
def test_scanner_resolves_imported_factory_assigned_to_local_client(
scanner: ModuleType,
) -> None:
source = """
from httpx import Client as HC
def dispatch(endpoint: str, bot_token: str, chat_id: str, payload: bytes) -> object:
sender = HC()
return sender.post(endpoint, content=payload)
"""
findings = scanner.scan_direct_bot_api_surfaces(
"scripts/ops/assigned_alias_provider.py",
source,
)
assert len(findings) == 1
assert findings[0]["method"] == "dynamic"
assert findings[0]["function"] == "dispatch"
@pytest.mark.parametrize("scanner", SCANNERS)
@pytest.mark.parametrize("upload_method", ["UploadString", "UploadData", "UploadFile"])
def test_scanner_detects_powershell_webclient_uploads_with_dynamic_endpoint(
scanner: ModuleType,
upload_method: str,
) -> None:
source = f"""
function Publish-LegacyPayload {{
param($BotToken, $ChatId, $Uri)
$webClient = New-Object System.Net.WebClient
$webClient.{upload_method}($Uri, $Payload)
}}
"""
findings = scanner.scan_direct_bot_api_surfaces(
"scripts/ops/legacy-provider.ps1",
source,
)
assert len(findings) == 1
assert findings[0]["method"] == "dynamic"
assert findings[0]["detection_kind"] == "custom_direct_sender"
assert findings[0]["function"] == "Publish-LegacyPayload"
@pytest.mark.parametrize("scanner", SCANNERS)
def test_scanner_excludes_only_canonical_gateway_final_exit(
scanner: ModuleType,
) -> None:
source = """
class TelegramGateway:
async def _send_request(self, token: str, payload: dict) -> object:
url = f"https://api.telegram.org/bot{token}/sendMessage"
return await self._http_client.post(url, json=payload)
"""
findings = scanner.scan_direct_bot_api_surfaces(
"apps/api/src/services/telegram_gateway.py",
source,
)
assert findings == []
@pytest.mark.parametrize("scanner", SCANNERS)
def test_scanner_does_not_allowlist_same_function_name_outside_gateway(
scanner: ModuleType,
) -> None:
source = """
import requests as rq
async def _send_request(
endpoint: str,
bot_token: str,
chat_id: str,
payload: dict,
) -> object:
return rq.post(endpoint, json=payload)
"""
findings = scanner.scan_direct_bot_api_surfaces(
"scripts/ops/telegram_gateway.py",
source,
)
assert len(findings) == 1
assert findings[0]["function"] == "_send_request"
assert findings[0]["detection_kind"] == "custom_direct_sender"
@pytest.mark.parametrize("scanner", SCANNERS)
def test_scanner_allowlists_only_class_qualified_gateway_final_exit(
scanner: ModuleType,
) -> None:
source = """
import requests as rq
async def _send_request(
endpoint: str,
bot_token: str,
chat_id: str,
payload: dict,
) -> object:
return rq.post(endpoint, json=payload)
class RogueGateway:
async def _send_request(
self,
endpoint: str,
bot_token: str,
chat_id: str,
payload: dict,
) -> object:
return rq.post(endpoint, json=payload)
class TelegramGateway:
async def _send_request(
self,
endpoint: str,
bot_token: str,
chat_id: str,
payload: dict,
) -> object:
return rq.post(endpoint, json=payload)
"""
findings = scanner.scan_direct_bot_api_surfaces(
"apps/api/src/services/telegram_gateway.py",
source,
)
assert {item["function_qualified"] for item in findings} == {
"_send_request",
"RogueGateway._send_request",
}
def test_no_new_bypass_guard_fails_closed_on_split_endpoint(tmp_path: Path) -> None:
_write_zero_baseline(tmp_path)
_write_minimal_gateway(tmp_path)
sender = tmp_path / "scripts/ops/split_sender.py"
sender.parent.mkdir(parents=True, exist_ok=True)
sender.write_text(
"""
import requests
def send_telegram(token: str) -> object:
base = "https://api.telegram.org/"
endpoint = base + f"bot{token}/" + "sendDocument"
return requests.post(endpoint, files={})
""",
encoding="utf-8",
)
report = NO_NEW_BYPASS_GUARD.build_report(tmp_path)
assert report["summary"]["current_direct_bot_api_call_count"] == 1
assert report["summary"]["new_bypass_count"] == 1
with pytest.raises(SystemExit, match="direct/custom bypass"):
NO_NEW_BYPASS_GUARD.validate(tmp_path)
def test_local_github_workflow_is_frozen_legacy_not_active_bypass(
tmp_path: Path,
) -> None:
_write_zero_baseline(tmp_path)
_write_minimal_gateway(tmp_path)
workflow = tmp_path / ".github/workflows/legacy-alert.yml"
workflow.parent.mkdir(parents=True, exist_ok=True)
workflow.write_text(
"""
steps:
- name: frozen legacy telegram sender
run: |
endpoint="https://api.telegram.org/bot${BOT_TOKEN}/sendMessage"
curl -X POST "$endpoint" -d "chat_id=${CHAT_ID}"
""",
encoding="utf-8",
)
inventory_report = INVENTORY.build_report(tmp_path)
inventory_item = inventory_report["direct_bot_api_calls"][0]
assert inventory_report["summary"]["direct_bot_api_call_count"] == 1
assert inventory_report["summary"]["active_direct_bot_api_call_count"] == 0
assert (
inventory_report["summary"]["github_frozen_legacy_direct_bot_api_call_count"]
== 1
)
assert inventory_item["source_truth_classification"] == (
"frozen_legacy_source_truth"
)
assert inventory_item["runtime_execution_authorized"] is False
assert inventory_item["workflow_execution_authorized"] is False
assert (
inventory_report["execution_boundaries"]["github_workflow_execution_authorized"]
is False
)
guard_report = NO_NEW_BYPASS_GUARD.build_report(tmp_path)
assert guard_report["status"] == "pass_no_direct_or_custom_bypass"
assert guard_report["summary"]["detected_direct_bot_api_call_count"] == 1
assert guard_report["summary"]["current_direct_bot_api_call_count"] == 0
assert guard_report["summary"]["new_bypass_count"] == 0
assert (
guard_report["summary"]["github_frozen_legacy_direct_bot_api_call_count"] == 1
)
frozen_item = guard_report["frozen_legacy_direct_bot_api_calls"][0]
assert frozen_item["source_truth_classification"] == ("frozen_legacy_source_truth")
assert frozen_item["runtime_execution_authorized"] is False
assert frozen_item["workflow_execution_authorized"] is False
assert (
guard_report["execution_boundaries"]["github_workflow_execution_authorized"]
is False
)
NO_NEW_BYPASS_GUARD.validate(tmp_path)
def test_agent99_has_no_direct_or_custom_telegram_sender_source() -> None:
source = (ROOT / "agent99-control-plane.ps1").read_text(encoding="utf-8")
for scanner in SCANNERS:
assert (
scanner.scan_direct_bot_api_surfaces("agent99-control-plane.ps1", source)
== []
)
assert "function Send-AgentTelegramRelay" not in source
assert "function Send-AgentTelegramPhotoDirect" not in source
assert "canonical_telegram_gateway_transport_required" in source
assert "providerSendPerformed = $false" in source
assert 'routeStatus = "blocked_no_egress"' in source