forked from ChelseaKR/queer-the-stacks
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnetguard.py
More file actions
133 lines (104 loc) · 4.91 KB
/
Copy pathnetguard.py
File metadata and controls
133 lines (104 loc) · 4.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
"""Runtime network guards for the privacy tests — measurement, not text search.
Two helpers, both used by ``tests/test_no_egress.py``:
* :func:`no_network` traps the socket primitives every Python HTTP client
ultimately goes through (``connect``, ``connect_ex``, ``sendto``,
``create_connection``, ``getaddrinfo``, ``gethostbyname``) and raises on the
first attempt. A code path executed inside it *cannot* silently reach the
network: either it makes no connection, or the test fails. This is what makes
the no-egress assertions measurements of behaviour rather than of source text.
* :func:`capture_requests` swaps ``requests.sessions.Session.send``, which sits
*below* ``requests.get``. Everything above it stays real — URL assembly, query
parameters, headers, redirect policy — so a test can assert exactly what would
have gone on the wire without a wire being there.
Neither helper is a production control; they exist so the guardrail tests can
observe the real request path.
"""
from __future__ import annotations
import socket
from collections.abc import Callable, Iterator, Mapping
from contextlib import contextmanager
from dataclasses import dataclass
from typing import Any, Optional
class EgressAttempted(AssertionError):
"""Raised when guarded code tries to open a network connection."""
@contextmanager
def no_network() -> Iterator[list[str]]:
"""Block (and record) every outbound socket operation inside the block.
Yields the list of attempts. It stays empty for a code path that never
reaches the network; any attempt both appends to it and raises
:class:`EgressAttempted` at the call site, so a swallowed exception cannot
hide the attempt from the assertion afterwards.
"""
attempts: list[str] = []
saved: list[tuple[object, str, object]] = []
def _install(label: str, owner: Any, attr: str) -> None:
original = getattr(owner, attr)
def _blocked(*args: object, **kwargs: object) -> object:
# Bound socket methods carry `self` first; module-level helpers do not.
target = args[1] if (owner is socket.socket and len(args) > 1) else (args[:1] or "")
attempts.append(f"{label}{target!r}")
raise EgressAttempted(f"network egress attempted: {label}{target!r}")
saved.append((owner, attr, original))
setattr(owner, attr, _blocked)
_install("socket.connect", socket.socket, "connect")
_install("socket.connect_ex", socket.socket, "connect_ex")
_install("socket.sendto", socket.socket, "sendto")
_install("socket.create_connection", socket, "create_connection")
_install("socket.getaddrinfo", socket, "getaddrinfo")
_install("socket.gethostbyname", socket, "gethostbyname")
try:
yield attempts
finally:
for owner, attr, original in saved:
setattr(owner, attr, original)
@dataclass(frozen=True)
class SentRequest:
"""One request as ``requests`` prepared it, captured below ``requests.get``."""
method: str
url: str
headers: Mapping[str, str]
body: Optional[object]
allow_redirects: bool
def as_text(self) -> str:
"""Everything that would have left this machine, as one lowercase string."""
header_text = " ".join(f"{k}: {v}" for k, v in self.headers.items())
return f"{self.method} {self.url} {header_text} {self.body or ''}".lower()
class _StubResponse:
"""The small ``requests.Response`` surface the clients actually use."""
def __init__(self, text: str, status_code: int) -> None:
self.text = text
self.status_code = status_code
self.headers: dict[str, str] = {}
def raise_for_status(self) -> None:
if self.status_code >= 400:
raise RuntimeError(f"http {self.status_code}")
@contextmanager
def capture_requests(
body: str = "{}",
status_code: int = 200,
responder: Optional[Callable[[SentRequest], object]] = None,
) -> Iterator[list[SentRequest]]:
"""Capture every prepared request instead of sending it.
Patches ``Session.send``, so URL building, headers, and redirect policy are
the real ones. ``responder`` may return a custom stand-in response.
"""
import requests
sent: list[SentRequest] = []
original = requests.sessions.Session.send
def _send(self: object, request: Any, **kwargs: Any) -> object:
captured = SentRequest(
method=str(request.method),
url=str(request.url),
headers=dict(request.headers),
body=request.body,
allow_redirects=bool(kwargs.get("allow_redirects", True)),
)
sent.append(captured)
if responder is not None:
return responder(captured)
return _StubResponse(body, status_code)
requests.sessions.Session.send = _send # type: ignore[method-assign] # test-only stub
try:
yield sent
finally:
requests.sessions.Session.send = original # type: ignore[method-assign] # restore