quash-api 0.1.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.
Files changed (115) hide show
  1. api_test_agent/__init__.py +1 -0
  2. api_test_agent/_arg_masking.py +76 -0
  3. api_test_agent/_log_redaction.py +161 -0
  4. api_test_agent/_ssrf.py +122 -0
  5. api_test_agent/arazzo.py +448 -0
  6. api_test_agent/billing/__init__.py +3 -0
  7. api_test_agent/billing/usage_tracker.py +157 -0
  8. api_test_agent/bundle.py +555 -0
  9. api_test_agent/capture/__init__.py +20 -0
  10. api_test_agent/capture/bindings.py +232 -0
  11. api_test_agent/capture/correlation.py +149 -0
  12. api_test_agent/capture/generate.py +115 -0
  13. api_test_agent/capture/har_superset.py +178 -0
  14. api_test_agent/capture/persistence.py +251 -0
  15. api_test_agent/capture/redaction.py +798 -0
  16. api_test_agent/capture/to_flows.py +117 -0
  17. api_test_agent/capture/to_mocks.py +70 -0
  18. api_test_agent/ci/__init__.py +17 -0
  19. api_test_agent/ci/arazzo_import.py +280 -0
  20. api_test_agent/ci/arazzo_schema.py +29 -0
  21. api_test_agent/ci/cli.py +169 -0
  22. api_test_agent/ci/loader.py +158 -0
  23. api_test_agent/ci/openapi_index.py +43 -0
  24. api_test_agent/ci/reporters.py +124 -0
  25. api_test_agent/ci/runner.py +385 -0
  26. api_test_agent/ci/schemas/arazzo-1.0.1.json +106 -0
  27. api_test_agent/cloud_rpc.py +871 -0
  28. api_test_agent/config/__init__.py +17 -0
  29. api_test_agent/config/models.py +179 -0
  30. api_test_agent/connector/__init__.py +121 -0
  31. api_test_agent/connector/allowlist.py +108 -0
  32. api_test_agent/connector/cloud.py +127 -0
  33. api_test_agent/connector/exchange.py +133 -0
  34. api_test_agent/connector/loop.py +134 -0
  35. api_test_agent/coverage.py +477 -0
  36. api_test_agent/env_state.py +133 -0
  37. api_test_agent/events/__init__.py +0 -0
  38. api_test_agent/events/emitter.py +466 -0
  39. api_test_agent/events/protocol.py +134 -0
  40. api_test_agent/flow_requirements.py +153 -0
  41. api_test_agent/ingest/__init__.py +4 -0
  42. api_test_agent/ingest/api_details.py +101 -0
  43. api_test_agent/ingest/curl_parser.py +168 -0
  44. api_test_agent/ingest/har_parser.py +255 -0
  45. api_test_agent/ingest/openapi.py +444 -0
  46. api_test_agent/ingest/postman_parser.py +383 -0
  47. api_test_agent/ingest/schema_infer.py +247 -0
  48. api_test_agent/ingest/sources.py +204 -0
  49. api_test_agent/manage.py +764 -0
  50. api_test_agent/relay_channel.py +99 -0
  51. api_test_agent/replay/__init__.py +21 -0
  52. api_test_agent/replay/bundle.py +2001 -0
  53. api_test_agent/replay/engine.py +65 -0
  54. api_test_agent/replay/ignorepaths.py +150 -0
  55. api_test_agent/replay/mock_store.py +156 -0
  56. api_test_agent/replay/resolver.py +597 -0
  57. api_test_agent/report.py +106 -0
  58. api_test_agent/run_suite.py +50 -0
  59. api_test_agent/stores/__init__.py +96 -0
  60. api_test_agent/stores/_atomic.py +54 -0
  61. api_test_agent/stores/_canonical.py +43 -0
  62. api_test_agent/stores/capture_provenance.py +66 -0
  63. api_test_agent/stores/coverage_ledger.py +61 -0
  64. api_test_agent/stores/environment_store.py +159 -0
  65. api_test_agent/stores/flow_store.py +270 -0
  66. api_test_agent/stores/ports.py +206 -0
  67. api_test_agent/stores/run_store.py +760 -0
  68. api_test_agent/stores/secret_resolver.py +148 -0
  69. api_test_agent/stores/spec_store.py +544 -0
  70. api_test_agent/stores/test_case_store.py +209 -0
  71. api_test_agent/stores/variable_store.py +120 -0
  72. api_test_agent/suite.py +270 -0
  73. api_test_agent/tools/__init__.py +7 -0
  74. api_test_agent/tools/author/__init__.py +1 -0
  75. api_test_agent/tools/author/delete_test_case.py +48 -0
  76. api_test_agent/tools/execute/__init__.py +4 -0
  77. api_test_agent/tools/execute/_digest.py +84 -0
  78. api_test_agent/tools/execute/baseline.py +203 -0
  79. api_test_agent/tools/execute/engine_factory.py +232 -0
  80. api_test_agent/tools/execute/http_request.py +192 -0
  81. api_test_agent/tools/execute/run_flow.py +482 -0
  82. api_test_agent/tools/execute/run_tests.py +510 -0
  83. api_test_engine/__init__.py +74 -0
  84. api_test_engine/assertions.py +531 -0
  85. api_test_engine/budget.py +670 -0
  86. api_test_engine/confusables.py +138 -0
  87. api_test_engine/credential_names.py +514 -0
  88. api_test_engine/diagnostics.py +346 -0
  89. api_test_engine/engine.py +1981 -0
  90. api_test_engine/entropy.py +86 -0
  91. api_test_engine/flow_runner.py +1027 -0
  92. api_test_engine/junit.py +103 -0
  93. api_test_engine/masking.py +106 -0
  94. api_test_engine/matcher_path.py +48 -0
  95. api_test_engine/models.py +626 -0
  96. api_test_engine/noise.py +480 -0
  97. api_test_engine/outcomes.py +408 -0
  98. api_test_engine/policy.py +319 -0
  99. api_test_engine/preview.py +167 -0
  100. api_test_engine/relay.py +296 -0
  101. api_test_engine/replay_headers.py +64 -0
  102. api_test_engine/response_diff.py +1085 -0
  103. api_test_engine/results.py +340 -0
  104. api_test_engine/run_stamp.py +158 -0
  105. api_test_engine/shape.py +274 -0
  106. api_test_engine/signatures.py +890 -0
  107. api_test_engine/substitution.py +536 -0
  108. api_test_engine/thirdparty/confusables.txt +9862 -0
  109. api_test_engine/traffic_fp.py +1030 -0
  110. api_test_engine/triage.py +185 -0
  111. quash_api-0.1.0.dist-info/METADATA +112 -0
  112. quash_api-0.1.0.dist-info/RECORD +115 -0
  113. quash_api-0.1.0.dist-info/WHEEL +4 -0
  114. quash_api-0.1.0.dist-info/entry_points.txt +2 -0
  115. quash_api-0.1.0.dist-info/licenses/LICENSE +7 -0
@@ -0,0 +1 @@
1
+ """API testing agent — LangGraph agent + tools over the pure api_test_engine."""
@@ -0,0 +1,76 @@
1
+ """Redact secret-carrying tool-call ARGUMENTS before they hit the event wire /
2
+ UI (review finding C2).
3
+
4
+ The parallel tool node streams each tool call's args to stdout/UI *before* the
5
+ tool runs. The LLM's own literal args — a ``set_variable(secret=True,
6
+ value=<literal>)`` or an ``http_request`` ``Authorization`` header — would
7
+ otherwise egress verbatim into LLM context, session history, and the terminal.
8
+ Value-based masking can't help: the literal is usually a BRAND-NEW secret being
9
+ registered, so it isn't in the known-secret list yet. So we redact by ARGUMENT
10
+ SHAPE at the emit boundary. This affects only the EMITTED copy; the tool still
11
+ receives the real args.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Any
18
+
19
+ _MASK = "****"
20
+
21
+ # A dict key (a body field, a query param, a nested field) whose NAME marks its
22
+ # value as a credential — masked at ANY depth. Broad on purpose: over-redacting
23
+ # the emitted event costs a little observability; under-redacting leaks a secret.
24
+ _SECRET_KEY_RE = re.compile(
25
+ r"(secret|password|passwd|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token"
26
+ r"|bearer|credential|cred|private[_-]?key|client[_-]?secret|session[_-]?(id|token)|passphrase|otp|pin)",
27
+ re.I,
28
+ )
29
+ # A HEADER name whose value is a credential (broader than the key regex — an
30
+ # ``X-Whatever-Auth`` header is a token; ``Accept``/``Content-Type`` are not).
31
+ _SECRET_HEADER_RE = re.compile(r"(authoriz|cookie|token|api[_-]?key|secret|credential|cred|session|auth|bearer|x-.*-key)", re.I)
32
+
33
+
34
+ def _scrub(obj: Any, *, in_headers: bool = False) -> Any:
35
+ """Recursively redact credential-shaped values in a copy of *obj*."""
36
+ if isinstance(obj, dict):
37
+ out: dict[Any, Any] = {}
38
+ for k, v in obj.items():
39
+ child_headers = bool(isinstance(k, str) and k.lower() == "headers")
40
+ hdr_name = isinstance(k, str) and _SECRET_HEADER_RE.search(k)
41
+ key_secret = isinstance(k, str) and _SECRET_KEY_RE.search(k)
42
+ if (in_headers and hdr_name) or key_secret:
43
+ # Mask a credential STRING; recurse a container; but leave a
44
+ # bool/number/None (a `secret: true` flag, an id) alone — a flag
45
+ # is not a credential, and masking it would corrupt tool logic.
46
+ if isinstance(v, str):
47
+ out[k] = _MASK
48
+ elif isinstance(v, (dict, list)):
49
+ out[k] = _scrub(v, in_headers=child_headers)
50
+ else:
51
+ out[k] = v
52
+ else:
53
+ out[k] = _scrub(v, in_headers=child_headers)
54
+ return out
55
+ if isinstance(obj, list):
56
+ return [_scrub(v, in_headers=in_headers) for v in obj]
57
+ return obj
58
+
59
+
60
+ def scrub_tool_call_args(name: str, args: Any) -> Any:
61
+ """Return a COPY of *args* with credential-shaped values redacted at any
62
+ depth (headers by name, secret-named body/query fields, and the documented
63
+ ``set_variable`` secret value). Never mutates the input — the tool must still
64
+ receive the real args."""
65
+ if not isinstance(args, dict):
66
+ return args
67
+ out = _scrub(args)
68
+ # set_variable(secret=True, value=...) — the `value` key isn't credential-
69
+ # named, so mask it explicitly. Read the ORIGINAL flag (the scrubbed copy's
70
+ # `secret` is untouched now, but the original is the source of truth).
71
+ if name == "set_variable" and args.get("secret") and "value" in out:
72
+ out["value"] = _MASK
73
+ return out
74
+
75
+
76
+ __all__ = ["scrub_tool_call_args"]
@@ -0,0 +1,161 @@
1
+ """The agent's log lines never carry a request URL's secrets (live smoke B1).
2
+
3
+ httpx logs every request at INFO (``HTTP Request: GET <url> "HTTP/1.1 200"``),
4
+ and the agent's stderr is relayed into the worker's log. :class:`RedactUrls`
5
+ rewrites every record — its message AND its exception traceback — and
6
+ :func:`install` also covers an UNCAUGHT exception (``sys.excepthook``). A URL
7
+ keeps its scheme, host and path and its query keys; a query VALUE stays only
8
+ when it is harmless: a credential-NAMED parameter (the engine's shared
9
+ ``credential_names`` rule — the cloud masks results with the same rule) or a
10
+ token-SHAPED value under any name (a captured token in ``?cursor=``) becomes
11
+ ``…``; userinfo and fragment are dropped; the run's loaded secret values
12
+ (:func:`set_secret_values`) are masked anywhere."""
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import re
18
+ import sys
19
+ import traceback
20
+ from collections.abc import Callable, Iterable
21
+ from typing import Final
22
+ from urllib.parse import urlsplit
23
+
24
+ from api_test_engine.credential_names import credential_query_name, token_shaped
25
+
26
+
27
+ class _LinearPattern:
28
+ r"""``re.compile(pattern).sub(repl, text)``, exactly, in linear time — for a
29
+ pattern whose every attempt first reads a RUN (here: a URL scheme) to its
30
+ end, and can fail only after it.
31
+
32
+ ``re`` tries such a pattern at every start inside a run, and each attempt
33
+ reads the same run to the same end and fails there the same way, so a run
34
+ of starts is quadratic: ``a.a.a…`` (a scheme at every letter, no ``://``
35
+ ever found) costs one scan per start. This attempts the SAME pattern
36
+ (``match``ed in place, so the leading ``\b`` still reads the text before
37
+ it) where the first group of *seek*'s next match begins, and when an
38
+ attempt fails it seeks on from where *run*, read from that start, ends:
39
+ every start in between reads that run to that end and fails there too.
40
+ From wherever the scan resumes (the text's start, a match's end, a run's
41
+ end), *seek* finds the first start the pattern matches from, or an
42
+ earlier one where its first step holds (so a failed attempt is always a
43
+ run that failed at its end). The same matches, in the same order, through
44
+ the same *repl*: the same text, and no bound on any length.
45
+
46
+ Ported from ``quash_cloud.apitest.result_masking._LinearPattern`` (same
47
+ class, same fix for the same regex shape) rather than imported: this
48
+ package must never import ``quash_cloud`` (the import-linter "engine is
49
+ pure"-style layering this repo also applies to the agent; see
50
+ ``api_test_agent/cloud_rpc.py``'s own note on the reverse direction)."""
51
+
52
+ def __init__(self, pattern: str, *, seek: str, run: str) -> None:
53
+ self.pattern = re.compile(pattern)
54
+ #: Group 1 marks a start (a group keeps ``re``'s fast literal scan).
55
+ self._seek = re.compile(seek)
56
+ #: ``*``-repeated, so it always matches.
57
+ self._run = re.compile(run)
58
+
59
+ def sub(self, repl: Callable[[re.Match[str]], str], text: str) -> str:
60
+ out: list[str] = []
61
+ kept = pos = 0
62
+ while (seek := self._seek.search(text, pos)) is not None:
63
+ at = seek.start(1)
64
+ match = self.pattern.match(text, at)
65
+ if match is None:
66
+ run = self._run.match(text, at)
67
+ assert run is not None # a ``*``-repeated run always matches
68
+ pos = max(run.end(), at + 1)
69
+ continue
70
+ out += (text[kept:at], repl(match))
71
+ kept = match.end()
72
+ pos = max(kept, at + 1)
73
+ out.append(text[kept:])
74
+ return "".join(out)
75
+
76
+
77
+ #: Its run is the scheme. It seeks, inside ``re``, the FIRST start of each
78
+ #: scheme run (a letter at a word boundary; the atomic group never retries a
79
+ #: later one) that ``://`` follows: a later start of the same run ends at the
80
+ #: same ``://``, so plain text costs no attempt at all.
81
+ _URL: Final[_LinearPattern] = _LinearPattern(
82
+ r"\b[A-Za-z][A-Za-z0-9+.-]*://[^\s'\"<>]+",
83
+ seek=r"(?<![A-Za-z0-9+.-])(?>[A-Za-z0-9+.-]*?(\b[A-Za-z]))(?=[A-Za-z0-9+.-]*+://)",
84
+ run=r"[A-Za-z0-9+.-]*")
85
+ _HIDDEN = "…"
86
+ _MASK = "****"
87
+ _SECRETS: list[str] = []
88
+
89
+
90
+ def set_secret_values(values: Iterable[str]) -> None:
91
+ """The run's loaded secret values (masking input only, never logged)."""
92
+ _SECRETS[:] = sorted({str(v) for v in values if v and len(str(v)) >= 4}, key=len,
93
+ reverse=True)
94
+
95
+
96
+ def _redact(url: str) -> str:
97
+ try:
98
+ parts = urlsplit(url)
99
+ host = parts.hostname or ""
100
+ port = f":{parts.port}" if parts.port else ""
101
+ except ValueError:
102
+ return _HIDDEN
103
+ if ":" in host: # an IPv6 literal
104
+ host = f"[{host}]"
105
+ out = f"{parts.scheme}://{host}{port}{parts.path}"
106
+ if parts.query:
107
+ pairs = []
108
+ for pair in parts.query.split("&"):
109
+ key, eq, value = pair.partition("=")
110
+ hide = eq and value and (credential_query_name(key) or token_shaped(value))
111
+ pairs.append(f"{key}={_HIDDEN}" if hide else pair)
112
+ out += "?" + "&".join(pairs)
113
+ return out
114
+
115
+
116
+ def redact_urls(text: str) -> str:
117
+ """*text* with every URL redacted (see the module docstring) and every
118
+ loaded secret value masked."""
119
+ text = _URL.sub(lambda m: _redact(m.group(0)), text)
120
+ for secret in _SECRETS:
121
+ text = text.replace(secret, _MASK)
122
+ return text
123
+
124
+
125
+ class RedactUrls(logging.Filter):
126
+ """Rewrites a record's message with :func:`redact_urls` (its args folded
127
+ in first) and its traceback / stack text too."""
128
+
129
+ def filter(self, record: logging.LogRecord) -> bool:
130
+ try:
131
+ message = record.getMessage()
132
+ except Exception: # noqa: BLE001 — a malformed record is dropped, never raised
133
+ return False
134
+ record.msg = redact_urls(message)
135
+ record.args = ()
136
+ if record.exc_info and not record.exc_text:
137
+ record.exc_text = "".join(traceback.format_exception(*record.exc_info)).rstrip()
138
+ if record.exc_text:
139
+ record.exc_text = redact_urls(record.exc_text)
140
+ if record.stack_info:
141
+ record.stack_info = redact_urls(record.stack_info)
142
+ return True
143
+
144
+
145
+ def _excepthook(kind, value, tb) -> None: # noqa: ANN001 — sys.excepthook's signature
146
+ sys.stderr.write(redact_urls("".join(traceback.format_exception(kind, value, tb))))
147
+
148
+
149
+ def install() -> None:
150
+ """Put :class:`RedactUrls` on every root handler and on the HTTP client
151
+ loggers themselves, and redact an uncaught exception's traceback.
152
+ Idempotent."""
153
+ targets: list[logging.Filterer] = [*logging.getLogger().handlers]
154
+ targets += [logging.getLogger(name) for name in ("httpx", "httpcore")]
155
+ for target in targets:
156
+ if not any(isinstance(f, RedactUrls) for f in target.filters):
157
+ target.addFilter(RedactUrls())
158
+ sys.excepthook = _excepthook
159
+
160
+
161
+ __all__ = ["RedactUrls", "install", "redact_urls", "set_secret_values"]
@@ -0,0 +1,122 @@
1
+ """SSRF guard for agent-driven URL fetches (review finding C3).
2
+
3
+ Every place the agent — or an imported spec/doc it was steered by (a prompt-
4
+ injection surface) — can make us fetch an arbitrary URL (``fetch_url``,
5
+ ``import_openapi`` by URL, the OpenAPI source loader, and the ``manage`` import
6
+ path) must refuse INTERNAL targets. Without this, an LLM-controlled URL reaches
7
+ ``169.254.169.254`` (cloud metadata), ``localhost``, or an RFC1918 host — with
8
+ the raw body returned into LLM context.
9
+
10
+ Guard: resolve the host and reject if ANY resolved address is loopback /
11
+ link-local (includes the metadata range) / private / reserved / multicast /
12
+ unspecified; validate the initial URL AND every redirect hop (redirects are
13
+ followed MANUALLY so a public URL cannot 3xx into an internal one). ``http_request``
14
+ keeps its own explicit host-allowlist rail; this guard covers the ingest/doc
15
+ fetchers that had none.
16
+
17
+ Residual (documented, out of scope this pass): DNS rebinding between our
18
+ resolution and httpx's own connect is not closed here — a fully robust version
19
+ pins the resolved IP and connects to it with the original Host header.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ipaddress
25
+ import socket
26
+ from typing import Any
27
+ from urllib.parse import urljoin, urlparse
28
+
29
+ import httpx
30
+
31
+
32
+ class SsrfError(Exception):
33
+ """A URL was refused because it targets a non-public / internal address."""
34
+
35
+
36
+ # Ranges the category flags below can miss depending on the Python point-release
37
+ # (RFC 6598 CGNAT hosts Alibaba Cloud metadata 100.100.100.200 and is common for
38
+ # k8s / internal load-balancers). Blocked explicitly so the guard is version-proof.
39
+ _EXTRA_BLOCKED = [
40
+ ipaddress.ip_network("100.64.0.0/10"), # RFC 6598 carrier-grade NAT
41
+ ipaddress.ip_network("192.0.0.0/24"), # IETF protocol assignments
42
+ ipaddress.ip_network("192.88.99.0/24"), # 6to4 relay anycast
43
+ ipaddress.ip_network("::ffff:0:0/96"), # IPv4-mapped IPv6 (defense in depth)
44
+ ]
45
+
46
+
47
+ def _addr_blocked(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
48
+ # Allow ONLY globally-routable public addresses. `not is_global` is the
49
+ # allowlist-style backstop that catches anything the explicit category flags
50
+ # miss (CGNAT, benchmarking, future special-use); the flags + explicit nets
51
+ # are belt-and-braces so a Python release that mis-classifies a range can't
52
+ # open a hole.
53
+ if (
54
+ not ip.is_global or ip.is_loopback or ip.is_link_local or ip.is_private
55
+ or ip.is_reserved or ip.is_multicast or ip.is_unspecified
56
+ ):
57
+ return True
58
+ return any(ip.version == net.version and ip in net for net in _EXTRA_BLOCKED)
59
+
60
+
61
+ def assert_public_url(url: str) -> None:
62
+ """Raise :class:`SsrfError` unless *url* is http(s) with a host that resolves
63
+ ENTIRELY to public addresses."""
64
+ parsed = urlparse(url)
65
+ if parsed.scheme not in ("http", "https"):
66
+ raise SsrfError(f"refusing non-http(s) URL scheme '{parsed.scheme or '?'}'")
67
+ host = parsed.hostname
68
+ if not host:
69
+ raise SsrfError("refusing URL with no host")
70
+ try:
71
+ infos = socket.getaddrinfo(
72
+ host, parsed.port or (443 if parsed.scheme == "https" else 80),
73
+ proto=socket.IPPROTO_TCP,
74
+ )
75
+ except socket.gaierror as exc:
76
+ raise SsrfError(f"refusing '{host}': DNS resolution failed ({exc})") from exc
77
+ for info in infos:
78
+ raw = info[4][0].split("%")[0] # strip any IPv6 scope id
79
+ try:
80
+ ip = ipaddress.ip_address(raw)
81
+ except ValueError as exc:
82
+ raise SsrfError(f"refusing '{host}': unparseable address {raw!r}") from exc
83
+ if _addr_blocked(ip):
84
+ raise SsrfError(f"refusing '{host}': resolves to non-public address {ip}")
85
+
86
+
87
+ def _next_redirect(resp: httpx.Response) -> str | None:
88
+ loc = resp.headers.get("location")
89
+ return urljoin(str(resp.url), loc) if resp.is_redirect and loc else None
90
+
91
+
92
+ async def safe_get(client: httpx.AsyncClient, url: str, *, max_redirects: int = 5, **kwargs: Any) -> httpx.Response:
93
+ """``client.get`` that validates the URL AND every redirect hop against
94
+ :func:`assert_public_url`. Redirects are followed manually (never httpx auto)
95
+ so a public URL cannot 3xx into an internal target."""
96
+ kwargs.pop("follow_redirects", None)
97
+ current = url
98
+ for _ in range(max_redirects + 1):
99
+ assert_public_url(current)
100
+ resp = await client.get(current, follow_redirects=False, **kwargs)
101
+ nxt = _next_redirect(resp)
102
+ if nxt is None:
103
+ return resp
104
+ current = nxt
105
+ raise SsrfError(f"too many redirects (> {max_redirects}) starting at {url}")
106
+
107
+
108
+ def safe_get_sync(url: str, *, max_redirects: int = 5, **kwargs: Any) -> httpx.Response:
109
+ """Synchronous counterpart of :func:`safe_get` for the sync ``manage`` path."""
110
+ kwargs.pop("follow_redirects", None)
111
+ current = url
112
+ for _ in range(max_redirects + 1):
113
+ assert_public_url(current)
114
+ resp = httpx.get(current, follow_redirects=False, **kwargs)
115
+ nxt = _next_redirect(resp)
116
+ if nxt is None:
117
+ return resp
118
+ current = nxt
119
+ raise SsrfError(f"too many redirects (> {max_redirects}) starting at {url}")
120
+
121
+
122
+ __all__ = ["SsrfError", "assert_public_url", "safe_get", "safe_get_sync"]