Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions packages/tangle-cli/src/tangle_cli/api_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@
default_auth_header,
default_base_url,
default_token,
describe_request_error,
format_http_status_error,
format_request_error,
http_status_line,
request_operation,
sanitize_url,
)
from .cli_helpers import api_arg_specs, load_args_or_exit
from .cli_options import (
Expand Down Expand Up @@ -185,14 +190,11 @@ def refresh(
include_env_credentials=not base_url_from_config,
)
except httpx.HTTPStatusError as exc:
message = f"HTTP {exc.response.status_code} {exc.response.reason_phrase}"
raise SystemExit(
f"Failed to fetch {_openapi_url(normalized_base_url)}: {message}"
) from exc
# Never echo the /openapi.json response body: an auth failure can
# reflect the credentials we just sent. Status line only.
raise SystemExit(_schema_fetch_error_message(normalized_base_url, exc)) from exc
except httpx.RequestError as exc:
raise SystemExit(
f"Failed to fetch {_openapi_url(normalized_base_url)}: {exc}"
) from exc
raise SystemExit(_schema_fetch_error_message(normalized_base_url, exc)) from exc
path_count = len(schema.get("paths", {}))
print(f"Cached OpenAPI schema for {normalized_base_url}")
print(f"Path: {path}")
Expand Down Expand Up @@ -452,11 +454,13 @@ def _invoke_operation_once(
include_env_credentials=include_env_credentials,
)
except httpx.HTTPStatusError as exc:
message = exc.response.text or exc.response.reason_phrase
print(message, file=sys.stderr)
raise SystemExit(exc.response.status_code) from exc
# One-line, credential-safe failure with a non-zero exit. The prior code
# raised the HTTP status as the exit code, but exit codes are 8-bit, so a
# status was truncated (404 -> 148, 500 -> 244) and multiples of 256
# reported success. A string exit prints to stderr and exits 1.
raise SystemExit(format_http_status_error(exc)) from exc
except httpx.RequestError as exc:
raise SystemExit(f"Failed to call {exc.request.url}: {exc}") from exc
raise SystemExit(format_request_error(exc)) from exc
except TypeError as exc:
raise SystemExit(str(exc)) from exc

Expand Down Expand Up @@ -697,6 +701,23 @@ def _validate_schema_source(value: str) -> str:
return normalized


def _schema_fetch_error_message(base_url: str, exc: Exception) -> str:
"""One-line, credential-safe failure for an /openapi.json fetch.

The response body is deliberately omitted for HTTP status errors so an auth
failure cannot reflect the credentials that were just sent.
"""

target = sanitize_url(_openapi_url(base_url))
if isinstance(exc, httpx.HTTPStatusError):
reason = http_status_line(exc)
elif isinstance(exc, httpx.RequestError):
reason = describe_request_error(exc)
else:
reason = exc.__class__.__name__
return f"Failed to fetch {target}: {reason}"


def _schema_fetch_failure_message(base_url: str, exc: Exception) -> str:
if isinstance(exc, httpx.HTTPStatusError):
reason = f"HTTP {exc.response.status_code} {exc.response.reason_phrase}"
Expand Down
162 changes: 161 additions & 1 deletion packages/tangle-cli/src/tangle_cli/api_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,23 @@
_MISSING = object()
_SENSITIVE_HEADER_NAMES = {"authorization", "cloud-auth", "cookie", "x-api-key"}
_SENSITIVE_KEY_RE = re.compile(
r"(authorization|authentication|(^|[-_])auth($|[-_])|cloud[-_]?auth|cookie|x[-_]?api[-_]?key|token|secret|password|credential|pre[-_]?signed[-_]?url|signed[-_]?url)",
r"(authorization|authentication|(^|[-_])auth($|[-_])|cloud[-_]?auth|cookie|(x[-_]?)?api[-_]?key|token|secret|password|credential|pre[-_]?signed[-_]?url|signed[-_]?url)",
re.IGNORECASE,
)
# Query parameters that carry the credential portion of a presigned/SAS URL
# (AWS SigV4, GCS, Azure). Redacting the signature neutralizes the grant.
_SIGNED_URL_QUERY_RE = re.compile(
r"^(x-(amz|goog|ms)-.*|sig|signature|awsaccesskeyid|googleaccessid)$",
re.IGNORECASE,
)
# Upper bound on backend-supplied error detail rendered on a single line.
_MAX_BACKEND_DETAIL_CHARS = 500
# Any ``scheme://...`` run embedded in free-form exception text; each match is
# routed through sanitize_url so userinfo and signed query params are redacted.
_EMBEDDED_URL_RE = re.compile(r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s'\"<>]+")
# Bare ``user:pass@host`` userinfo that appears without a scheme (e.g. proxy
# diagnostics). Requires a colon-bearing userinfo terminated by ``@``.
_BARE_USERINFO_RE = re.compile(r"[^\s/@:]+:[^\s/@]+@")
_REDACTED = "<redacted>"
_REDACTED_DOCUMENT = "<redacted document>"
_OPAQUE_DOCUMENT_KEY_NAMES = {
Expand Down Expand Up @@ -90,6 +104,152 @@ def _content_to_text(content: bytes | str | None) -> str:
return _safe_json_text(parsed)


def _is_sensitive_query_key(key: str) -> bool:
stripped = key.strip()
return bool(_SENSITIVE_KEY_RE.search(stripped) or _SIGNED_URL_QUERY_RE.match(stripped))


def sanitize_url(url: Any) -> str:
"""Return *url* with credentials removed so it is safe to display or log.

Strips any ``user:password@`` userinfo and redacts the values of query
parameters that look like tokens, credentials, or presigned/SAS-URL
signatures. The scheme, host, port, path, and non-sensitive query keys are
preserved so the target stays recognizable.
"""

text = str(url)
try:
parsed = urllib.parse.urlsplit(text)
except ValueError:
return _REDACTED
if not parsed.scheme and not parsed.netloc:
return text
host = parsed.hostname or ""
# ``hostname`` unwraps IPv6 literals, so re-bracket them before appending an
# optional port; otherwise ``[2001:db8::1]:8443`` becomes ambiguous garbage.
if ":" in host:
host = f"[{host}]"
netloc = f"{_REDACTED}@{host}" if (parsed.username or parsed.password) else host
if parsed.port is not None:
netloc = f"{netloc}:{parsed.port}"
query = parsed.query
if query:
pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
query = urllib.parse.urlencode(
[
(key, _REDACTED if _is_sensitive_query_key(key) else value)
for key, value in pairs
],
safe="<>",
)
return urllib.parse.urlunsplit((parsed.scheme, netloc, parsed.path, query, parsed.fragment))


def _bounded_detail(text: str | None) -> str:
"""Collapse whitespace and cap length of backend-supplied error detail."""

if not text:
return ""
collapsed = " ".join(str(text).split())
if len(collapsed) > _MAX_BACKEND_DETAIL_CHARS:
collapsed = collapsed[:_MAX_BACKEND_DETAIL_CHARS].rstrip() + "…"
return collapsed


def http_status_line(exc: httpx.HTTPStatusError) -> str:
"""Return the ``HTTP <status> <reason>`` summary for a status error."""

response = exc.response
return f"HTTP {response.status_code} {response.reason_phrase}".strip()


def format_http_status_error(exc: httpx.HTTPStatusError, *, include_detail: bool = True) -> str:
"""Build a concise one-line message for an httpx HTTP status error.

Includes the status, request method, and a credential-safe URL. When
*include_detail* is set, a whitespace-normalized and length-bounded slice of
the response body is appended so backend messages remain visible without
dumping multi-line or oversized payloads.
"""

request = exc.request
method = request.method if request is not None else "?"
url = sanitize_url(request.url) if request is not None else "?"
message = f"{http_status_line(exc)} for {method} {url}"
if include_detail:
try:
body = exc.response.text
except Exception: # pragma: no cover - defensive: streamed/undecodable body
body = ""
detail = _bounded_detail(body)
if detail:
message = f"{message}: {detail}"
return message


def _scrub_secret_text(text: str) -> str:
"""Redact URLs and bare userinfo embedded in free-form exception text.

A crafted or third-party ``httpx`` exception can carry a proxy URL, signed
query, or ``user:pass@host`` inside its message. Never emit that raw: route
every ``scheme://`` run through :func:`sanitize_url` and strip any remaining
schemeless userinfo, while leaving benign diagnostics (errno, TLS reason)
intact.
"""

def _replace_url(match: re.Match[str]) -> str:
raw = match.group(0)
trailing = ""
while raw and raw[-1] in ").,;'\"":
trailing = raw[-1] + trailing
raw = raw[:-1]
return sanitize_url(raw) + trailing

scrubbed = _EMBEDDED_URL_RE.sub(_replace_url, text)
return _BARE_USERINFO_RE.sub(f"{_REDACTED}@", scrubbed)


def describe_request_error(exc: httpx.RequestError) -> str:
"""Return an actionable, credential-safe reason for an httpx request error.

Connection, timeout, proxy, and TLS failures are labeled so the user knows
what to check; the underlying detail is included when it adds information.
Any URL or userinfo embedded in the exception text is redacted first.
"""

detail = _scrub_secret_text(" ".join(str(exc).split()))
lowered = detail.lower()
if isinstance(exc, httpx.ProxyError):
return f"proxy error: {detail}" if detail else "proxy error"
if isinstance(exc, httpx.TimeoutException):
label = {
httpx.ConnectTimeout: "connection timed out",
httpx.ReadTimeout: "read timed out",
httpx.WriteTimeout: "write timed out",
httpx.PoolTimeout: "connection pool timed out",
}.get(type(exc), "request timed out")
return f"{label}: {detail}" if detail and label not in lowered else label
if isinstance(exc, httpx.ConnectError):
if any(token in lowered for token in ("ssl", "certificate", "tls", "handshake")):
return f"TLS error: {detail}" if detail else "TLS error"
return f"connection failed: {detail}" if detail else "connection failed"
if detail:
return detail
return exc.__class__.__name__


def format_request_error(exc: httpx.RequestError) -> str:
"""Build a concise one-line message for an httpx connection-level error."""

request = getattr(exc, "request", None)
reason = describe_request_error(exc)
if request is None:
return f"Failed to reach the backend: {reason}"
url = sanitize_url(request.url)
return f"Failed to reach {request.method} {url}: {reason}"


def log_http_exchange(
logger: Any,
*,
Expand Down
Loading