sre-agent-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,146 @@
1
+ """Backend address rules: where the agent may send a request.
2
+
3
+ In plain words, every live backend in this project — Prometheus,
4
+ Kubernetes, Datadog, Azure Monitor — and the command-line tool's
5
+ own server address take a base address from whoever sets the
6
+ agent up, and some of them send a credential to that address as
7
+ a request header. Finding F-05 in the security review named the
8
+ risk: a mistyped or hostile address would receive a live token,
9
+ and a plain ``http://`` address would carry it across a network
10
+ unencrypted.
11
+
12
+ This module is the one place that risk is answered, so every
13
+ backend applies the same rules:
14
+
15
+ - **https, or loopback http, and nothing else.** An ``https://``
16
+ address is accepted. A plain ``http://`` address is accepted
17
+ only when its host is on this same machine — ``localhost``,
18
+ ``127.0.0.0/8`` or ``::1`` — which is where the demo backends
19
+ and the local server live. Plain http to any other host is
20
+ refused, and so is every other scheme (``ftp:``, ``file:``,
21
+ a bare host with no scheme, and the rest).
22
+ - **No credentials inside the address.** An address carrying a
23
+ user or password (``https://user:secret@host``) is refused:
24
+ credentials belong in the header a backend adds on purpose,
25
+ never pasted into an address that gets logged and copied.
26
+ - **An optional allow-list of hosts.** A caller may pass
27
+ ``allowed_hosts`` — host names, optionally with a port, such
28
+ as ``api.datadoghq.com`` or ``prometheus.internal:9090``. When
29
+ a list is given, the address's host (and its port, when the
30
+ entry names one) must be on it. The list can only narrow where
31
+ the agent may call; it can never widen the scheme rule — an
32
+ allow-list entry does not make plain http to a remote host
33
+ acceptable.
34
+
35
+ The check runs when a backend is built, before any request is
36
+ made, and raises ``ValueError`` with a plain reason. Addresses
37
+ still come only from operator code or configuration — never
38
+ from telemetry, an alert or a model output — and that boundary,
39
+ stated in the review, is unchanged; these rules are the second
40
+ layer, for the day an operator's address is wrong.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import ipaddress
46
+ import urllib.parse
47
+ from collections.abc import Iterable
48
+
49
+ _LOOPBACK_NAMES = {"localhost"}
50
+
51
+
52
+ def _is_loopback(host: str) -> bool:
53
+ """True when *host* names this same machine only."""
54
+ if host.lower() in _LOOPBACK_NAMES:
55
+ return True
56
+ try:
57
+ return ipaddress.ip_address(host).is_loopback
58
+ except ValueError:
59
+ return False
60
+
61
+
62
+ def _host_matches(host: str, port: int | None, entry: str) -> bool:
63
+ """True when one allow-list *entry* names this host (and port).
64
+
65
+ An entry is a host name, optionally with ``:port``. A bare
66
+ host entry allows that host on any port; an entry with a
67
+ port allows only that port. Matching is case-insensitive,
68
+ as host names are.
69
+ """
70
+ entry = entry.strip().lower()
71
+ if not entry:
72
+ return False
73
+ if entry.startswith("["): # a bracketed IPv6 entry, [::1] or [::1]:8443
74
+ end = entry.find("]")
75
+ if end == -1:
76
+ return False
77
+ entry_host = entry[1:end]
78
+ rest = entry[end + 1 :]
79
+ entry_port: int | None = None
80
+ if rest.startswith(":"):
81
+ try:
82
+ entry_port = int(rest[1:])
83
+ except ValueError:
84
+ return False
85
+ elif rest:
86
+ return False
87
+ elif entry.count(":") == 1:
88
+ entry_host, _, port_text = entry.partition(":")
89
+ try:
90
+ entry_port = int(port_text)
91
+ except ValueError:
92
+ return False
93
+ else:
94
+ entry_host, entry_port = entry, None
95
+ if host.lower() != entry_host:
96
+ return False
97
+ return entry_port is None or entry_port == port
98
+
99
+
100
+ def validate_backend_url(
101
+ url: str,
102
+ *,
103
+ allowed_hosts: Iterable[str] | None = None,
104
+ what: str = "backend address",
105
+ ) -> str:
106
+ """Check one base address against the rules; return it cleaned.
107
+
108
+ The returned address is the caller's, stripped of surrounding
109
+ whitespace and any trailing slash, so a backend can append
110
+ its paths to it directly. Anything outside the rules raises
111
+ ``ValueError`` with a plain reason naming *what* was checked.
112
+ """
113
+ cleaned = url.strip().rstrip("/") if isinstance(url, str) else ""
114
+ if not cleaned:
115
+ raise ValueError(f"{what} must not be empty")
116
+ if any(ch.isspace() for ch in cleaned):
117
+ raise ValueError(f"{what} must not contain spaces: {url!r}")
118
+ parsed = urllib.parse.urlparse(cleaned)
119
+ host = parsed.hostname
120
+ if parsed.scheme not in {"http", "https"} or not host:
121
+ raise ValueError(
122
+ f"{what} must be an http:// or https:// address with a host: {url!r}"
123
+ )
124
+ if parsed.username or parsed.password:
125
+ raise ValueError(
126
+ f"{what} must not carry a user or password in the address: {url!r}"
127
+ )
128
+ if parsed.scheme == "http" and not _is_loopback(host):
129
+ raise ValueError(
130
+ f"{what} uses plain http to a host that is not on this "
131
+ f"machine ({host}); use https:// for any address beyond "
132
+ "localhost, so a credential cannot cross a network "
133
+ "unencrypted"
134
+ )
135
+ if allowed_hosts is not None:
136
+ entries = list(allowed_hosts)
137
+ try:
138
+ port = parsed.port
139
+ except ValueError:
140
+ port = -1 # an unparseable port matches no entry
141
+ if not any(_host_matches(host, port, entry) for entry in entries):
142
+ raise ValueError(
143
+ f"{what} host {host} is not on the allow-list "
144
+ f"for this backend: {sorted(entries)}"
145
+ )
146
+ return cleaned