Files
crucible-agent-build-fastap…/tests/test_checker.py

109 lines
4.5 KiB
Python

import asyncio
import json
import logging
import socket
import pytest
from app.checker import EndpointChecker, redact_url
from app.models import State
from app.settings import Settings
@pytest.fixture
def checker() -> EndpointChecker:
return EndpointChecker(Settings(request_timeout_seconds=1, connect_timeout_seconds=1,
max_redirects=2))
@pytest.mark.asyncio
async def test_public_address_is_pinned_for_request(checker: EndpointChecker, monkeypatch) -> None:
async def resolve(_host: str, _port: int) -> list[str]:
return ["93.184.216.34"]
seen: list[str] = []
async def request(_url: str, addresses: list[str]) -> tuple[int, None]:
seen.extend(addresses)
return 200, None
monkeypatch.setattr(checker, "_resolve", resolve)
monkeypatch.setattr(checker, "_request_once", request)
result = await checker.check("https://example.com/?token=secret")
assert result.state == State.UP
assert seen == ["93.184.216.34"]
assert "secret" not in (result.final_url or "")
@pytest.mark.asyncio
@pytest.mark.parametrize("address", ["127.0.0.1", "10.0.0.1", "169.254.1.1", "::1", "192.0.2.1"])
async def test_dns_rejects_non_global_answers(checker: EndpointChecker, monkeypatch, address: str) -> None:
loop = asyncio.get_running_loop()
def answers(*_args, **_kwargs):
family = socket.AF_INET6 if ":" in address else socket.AF_INET
return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", (address, 443))]
monkeypatch.setattr(loop, "getaddrinfo", answers)
with pytest.raises(Exception, match="globally routable"):
await checker._resolve("attacker.invalid", 443)
@pytest.mark.asyncio
async def test_mixed_dns_answer_is_rejected(checker: EndpointChecker, monkeypatch) -> None:
loop = asyncio.get_running_loop()
def answers(*_args, **_kwargs):
return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 80))
for ip in ("93.184.216.34", "127.0.0.1")]
monkeypatch.setattr(loop, "getaddrinfo", answers)
with pytest.raises(Exception):
await checker._resolve("rebinding.invalid", 80)
@pytest.mark.asyncio
async def test_redirect_target_is_resolved_and_blocked(checker: EndpointChecker, monkeypatch) -> None:
calls = 0
async def resolve(host: str, _port: int) -> list[str]:
if host == "localhost":
from app.checker import SecurityError
raise SecurityError("destination is not globally routable")
return ["93.184.216.34"]
async def request(_url: str, _addresses: list[str]) -> tuple[int, str]:
nonlocal calls
calls += 1
return 302, "http://localhost/admin?key=secret"
monkeypatch.setattr(checker, "_resolve", resolve)
monkeypatch.setattr(checker, "_request_once", request)
result = await checker.check("https://example.com")
assert result.state == State.ERROR
assert result.error == "blocked_destination"
assert calls == 1
@pytest.mark.asyncio
async def test_timeout_mapping(checker: EndpointChecker, monkeypatch) -> None:
async def resolve(_host: str, _port: int) -> list[str]:
return ["93.184.216.34"]
async def request(_url: str, _addresses: list[str]):
raise asyncio.TimeoutError
monkeypatch.setattr(checker, "_resolve", resolve)
monkeypatch.setattr(checker, "_request_once", request)
result = await checker.check("https://example.com")
assert result.state == State.ERROR and result.error == "timeout"
def test_redaction_removes_credentials_query_and_fragment() -> None:
value = redact_url("https://user:secret@example.com/path?token=abc&x=1#frag")
assert value == "https://example.com/path?REDACTED"
assert all(secret not in value for secret in ("user", "secret", "abc", "frag"))
@pytest.mark.asyncio
async def test_structured_logs_do_not_leak_query(checker: EndpointChecker, monkeypatch, caplog) -> None:
async def resolve(_host: str, _port: int) -> list[str]:
return ["93.184.216.34"]
async def request(_url: str, _addresses: list[str]) -> tuple[int, None]:
return 500, None
monkeypatch.setattr(checker, "_resolve", resolve)
monkeypatch.setattr(checker, "_request_once", request)
with caplog.at_level(logging.INFO, logger="endpoint_monitor.checker"):
result = await checker.check("https://example.com/a?api_key=supersecret")
assert result.state == State.DOWN
assert "supersecret" not in caplog.text
assert all("event" in json.loads(record.message) for record in caplog.records)