496 lines
14 KiB
Python
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
|