e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
578 lines
20 KiB
Python
578 lines
20 KiB
Python
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
# OpenSandbox egress system addon.
|
|
#
|
|
# Always loaded by the egress mitmproxy launcher. Stays transparent on the
|
|
# wire (does not add or alter headers that would reveal the proxy to peers).
|
|
#
|
|
# Behavior:
|
|
# 1. Forces streaming for SSE / chunked responses so each chunk is forwarded
|
|
# immediately, bypassing the stream_large_bodies=1m buffer set in config.yaml
|
|
# (which otherwise stalls LLM-style small-chunk streams).
|
|
# 2. Acts as Credential Proxy when the egress sidecar has an active
|
|
# Credential Vault revision. The active revision is stored in the Go
|
|
# sidecar process and read over an egress-container-private Unix socket.
|
|
# Credential values are not logged, and response header values containing
|
|
# credentials are redacted. Response bodies are not rewritten by default.
|
|
# 3. Implements SNI-aware ignore_hosts for transparent mode. mitmproxy's
|
|
# built-in ignore_hosts check in transparent mode matches against the
|
|
# destination IP first; the SNI hostname is only available inside the TLS
|
|
# ClientHello, which arrives after the initial check. This addon re-checks
|
|
# the same ignore_hosts patterns against the SNI hostname at the
|
|
# tls_clienthello layer and sets ignore_connection=True when a match is
|
|
# found, ensuring domain-based TLS pass-through works reliably.
|
|
#
|
|
# User-defined addons can be loaded alongside this script via
|
|
# OPENSANDBOX_EGRESS_MITMPROXY_SCRIPT (comma-separated for multiple scripts).
|
|
from __future__ import annotations
|
|
|
|
import http.client as http_client
|
|
import json
|
|
import os
|
|
import re
|
|
import socket
|
|
import time
|
|
from typing import Any
|
|
from urllib.parse import quote, quote_plus, unquote
|
|
|
|
from mitmproxy import ctx, http
|
|
from mitmproxy.tls import ClientHelloData
|
|
|
|
|
|
CREDENTIAL_PROXY_SOCKET_ENV = "OPENSANDBOX_CREDENTIAL_PROXY_SOCKET"
|
|
DEFAULT_CREDENTIAL_PROXY_SOCKET = "/run/opensandbox/credential-proxy/active.sock"
|
|
ACTIVE_VAULT_PATH = "/credential-vault/_active"
|
|
VAULT_CACHE_TTL_SECONDS = 0.5
|
|
FLOW_REDACTIONS_KEY = "opensandbox_credential_redactions"
|
|
HEADER_SUBSTITUTION_DENYLIST = {
|
|
"host",
|
|
"content-length",
|
|
"transfer-encoding",
|
|
"connection",
|
|
"upgrade",
|
|
"te",
|
|
"trailer",
|
|
"proxy-authorization",
|
|
"proxy-authenticate",
|
|
"forwarded",
|
|
"x-forwarded-for",
|
|
"x-forwarded-host",
|
|
"x-forwarded-proto",
|
|
}
|
|
|
|
|
|
class ActiveVault:
|
|
def __init__(
|
|
self,
|
|
revision: int,
|
|
bindings: list[dict[str, Any]],
|
|
redactions: list[str],
|
|
) -> None:
|
|
self.revision = revision
|
|
self.bindings = bindings
|
|
self.redactions = redactions
|
|
|
|
|
|
_vault_cache: ActiveVault | None = None
|
|
_vault_cache_loaded_at = 0.0
|
|
|
|
|
|
class UnixSocketHTTPConnection(http_client.HTTPConnection):
|
|
def __init__(self, socket_path: str, timeout: float) -> None:
|
|
super().__init__("credential-proxy", timeout=timeout)
|
|
self.socket_path = socket_path
|
|
|
|
def connect(self) -> None:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
sock.settimeout(self.timeout)
|
|
sock.connect(self.socket_path)
|
|
self.sock = sock
|
|
|
|
|
|
def tls_clienthello(data: ClientHelloData) -> None:
|
|
"""Re-check ignore_hosts patterns against SNI hostname.
|
|
|
|
In transparent mode, mitmproxy checks ignore_hosts against the
|
|
destination IP:port before the TLS handshake. If the check fails at
|
|
that stage (SNI not yet available), we get a second chance here with
|
|
the actual hostname from the ClientHello SNI extension.
|
|
"""
|
|
sni = data.client_hello.sni
|
|
if not sni:
|
|
return
|
|
|
|
patterns = ctx.options.ignore_hosts
|
|
if not patterns:
|
|
return
|
|
|
|
for pattern in patterns:
|
|
try:
|
|
if re.search(pattern, sni):
|
|
data.ignore_connection = True
|
|
return
|
|
except re.error:
|
|
pass
|
|
|
|
|
|
def _load_active_vault() -> ActiveVault | None:
|
|
global _vault_cache, _vault_cache_loaded_at
|
|
now = time.monotonic()
|
|
if _vault_cache is not None and now - _vault_cache_loaded_at < VAULT_CACHE_TTL_SECONDS:
|
|
return _vault_cache
|
|
|
|
socket_path = (
|
|
os.environ.get(CREDENTIAL_PROXY_SOCKET_ENV, "").strip()
|
|
or DEFAULT_CREDENTIAL_PROXY_SOCKET
|
|
)
|
|
connection = UnixSocketHTTPConnection(socket_path, timeout=0.25)
|
|
try:
|
|
connection.request("GET", ACTIVE_VAULT_PATH)
|
|
response = connection.getresponse()
|
|
body = response.read()
|
|
if response.status == 404:
|
|
_vault_cache = None
|
|
_vault_cache_loaded_at = now
|
|
return None
|
|
if response.status != 200:
|
|
ctx.log.warn(
|
|
f"credential proxy: active vault lookup failed with HTTP {response.status}"
|
|
)
|
|
_vault_cache = None
|
|
_vault_cache_loaded_at = now
|
|
return None
|
|
payload = json.loads(body.decode("utf-8"))
|
|
except Exception as exc: # noqa: BLE001 - mitm addon must not crash traffic handling
|
|
ctx.log.warn(f"credential proxy: active vault lookup failed: {exc}")
|
|
_vault_cache = None
|
|
_vault_cache_loaded_at = now
|
|
return None
|
|
finally:
|
|
connection.close()
|
|
|
|
bindings = payload.get("bindings") or []
|
|
redactions = [v for v in (payload.get("redactions") or []) if isinstance(v, str) and v]
|
|
_vault_cache = ActiveVault(
|
|
revision=int(payload.get("revision") or 0),
|
|
bindings=bindings,
|
|
redactions=redactions,
|
|
)
|
|
_vault_cache_loaded_at = now
|
|
return _vault_cache
|
|
|
|
|
|
def _request_host(flow: http.HTTPFlow) -> str:
|
|
host = flow.request.pretty_host or flow.request.host or ""
|
|
return host.rstrip(".").lower()
|
|
|
|
|
|
def _request_port(flow: http.HTTPFlow) -> int:
|
|
if flow.request.port:
|
|
return int(flow.request.port)
|
|
return 443 if flow.request.scheme == "https" else 80
|
|
|
|
|
|
def _request_path(flow: http.HTTPFlow) -> str:
|
|
path = flow.request.path or "/"
|
|
return path.split("?", 1)[0] or "/"
|
|
|
|
|
|
_DOT_SEGMENT_RE = re.compile(r"/\.\.(/|$)")
|
|
|
|
|
|
def _path_is_ambiguous(raw_path: str) -> bool:
|
|
"""Return True if the raw request path contains ambiguous segments.
|
|
|
|
Legitimate HTTP clients resolve dot segments before sending. Raw ``..``
|
|
or percent-encoded separators on the wire indicate an attempt to confuse
|
|
path-based authorization checks because the canonical path seen by the
|
|
upstream server may differ from the raw prefix matched here.
|
|
"""
|
|
path = raw_path.split("?", 1)[0]
|
|
|
|
# Only match ``..`` as a complete path segment (/../ or trailing /..).
|
|
if _DOT_SEGMENT_RE.search(path):
|
|
return True
|
|
|
|
# Iteratively decode to catch nested encodings like %252e%252e or %252f.
|
|
decoded = path
|
|
for _ in range(10):
|
|
lower = decoded.lower()
|
|
if "%2f" in lower or "%5c" in lower:
|
|
return True
|
|
if "\\" in decoded:
|
|
return True
|
|
next_decoded = unquote(decoded)
|
|
if next_decoded == decoded:
|
|
break
|
|
decoded = next_decoded
|
|
if _DOT_SEGMENT_RE.search(decoded):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _host_matches(host: str, pattern: str) -> tuple[bool, int]:
|
|
pattern = pattern.rstrip(".").lower()
|
|
if pattern.startswith("*."):
|
|
suffix = pattern[1:]
|
|
apex = pattern[2:]
|
|
return host.endswith(suffix) and host != apex, 1
|
|
return host == pattern, 2
|
|
|
|
|
|
def _path_matches(path: str, pattern: str) -> bool:
|
|
if pattern.endswith("*"):
|
|
return path.startswith(pattern[:-1])
|
|
return path == pattern
|
|
|
|
|
|
def _binding_matches(flow: http.HTTPFlow, binding: dict[str, Any]) -> tuple[bool, int]:
|
|
match = binding.get("match") or {}
|
|
scheme = (flow.request.scheme or "").lower()
|
|
host = _request_host(flow)
|
|
port = _request_port(flow)
|
|
method = (flow.request.method or "").upper()
|
|
path = _request_path(flow)
|
|
|
|
schemes = match.get("schemes") or ["https"]
|
|
if scheme not in schemes:
|
|
return False, 0
|
|
canonical_port = 443 if scheme == "https" else 80
|
|
if port != canonical_port:
|
|
return False, 0
|
|
if method not in [m.upper() for m in (match.get("methods") or ["GET", "POST", "PUT", "PATCH", "DELETE"])]:
|
|
return False, 0
|
|
if not any(_path_matches(path, p) for p in (match.get("paths") or ["/*"])):
|
|
return False, 0
|
|
|
|
best_precedence = 0
|
|
for pattern in match.get("hosts") or []:
|
|
ok, precedence = _host_matches(host, pattern)
|
|
if ok and precedence > best_precedence:
|
|
best_precedence = precedence
|
|
return best_precedence > 0, best_precedence
|
|
|
|
|
|
def _select_binding(flow: http.HTTPFlow, vault: ActiveVault) -> dict[str, Any] | None:
|
|
matches: list[tuple[int, dict[str, Any]]] = []
|
|
for binding in vault.bindings:
|
|
ok, precedence = _binding_matches(flow, binding)
|
|
if ok:
|
|
matches.append((precedence, binding))
|
|
if not matches:
|
|
return None
|
|
|
|
highest = max(precedence for precedence, _ in matches)
|
|
selected = [binding for precedence, binding in matches if precedence == highest]
|
|
if len(selected) != 1:
|
|
flow.response = http.Response.make(
|
|
403,
|
|
b"credential binding ambiguous\n",
|
|
{"content-type": "text/plain"},
|
|
)
|
|
ctx.log.warn(
|
|
"credential proxy: ambiguous binding match for "
|
|
f"{flow.request.method} {_request_host(flow)}{_request_path(flow)}"
|
|
)
|
|
return None
|
|
return selected[0]
|
|
|
|
|
|
def _split_path_query(raw_path: str) -> tuple[str, str | None]:
|
|
if "?" not in raw_path:
|
|
return raw_path or "/", None
|
|
path, query = raw_path.split("?", 1)
|
|
return path or "/", query
|
|
|
|
|
|
def _request_body_bytes(flow: http.HTTPFlow) -> bytes | None:
|
|
body = getattr(flow.request, "raw_content", None)
|
|
if body is None:
|
|
body = getattr(flow.request, "content", None)
|
|
if body is None:
|
|
return None
|
|
if isinstance(body, str):
|
|
return body.encode("utf-8")
|
|
return body
|
|
|
|
|
|
def _set_request_body_bytes(flow: http.HTTPFlow, body: bytes) -> None:
|
|
flow.request.content = body
|
|
if "transfer-encoding" in flow.request.headers:
|
|
del flow.request.headers["transfer-encoding"]
|
|
flow.request.headers["content-length"] = str(len(body))
|
|
|
|
|
|
def _encoded_substitution_value(value: str, surface: str, content_type: str = "") -> str:
|
|
if surface in {"path", "query"}:
|
|
return quote(value, safe="")
|
|
if surface == "body":
|
|
normalized_type = content_type.split(";", 1)[0].strip().lower()
|
|
if normalized_type == "application/json" or normalized_type.endswith("+json"):
|
|
return json.dumps(value)[1:-1]
|
|
if normalized_type == "application/x-www-form-urlencoded":
|
|
return quote_plus(value, safe="")
|
|
return value
|
|
|
|
|
|
def _replace_literals_once(text: str, replacements: list[tuple[str, str]]) -> tuple[str, list[int]]:
|
|
# Apply replacements against the original text so inserted credential values
|
|
# are never scanned again for later placeholders.
|
|
if not replacements:
|
|
return text, []
|
|
|
|
parts: list[str] = []
|
|
applied: list[int] = []
|
|
applied_set: set[int] = set()
|
|
i = 0
|
|
while i < len(text):
|
|
selected_index = -1
|
|
selected_placeholder = ""
|
|
selected_replacement = ""
|
|
for index, (placeholder, replacement) in enumerate(replacements):
|
|
if len(placeholder) <= len(selected_placeholder):
|
|
continue
|
|
if text.startswith(placeholder, i):
|
|
selected_index = index
|
|
selected_placeholder = placeholder
|
|
selected_replacement = replacement
|
|
if selected_index >= 0:
|
|
parts.append(selected_replacement)
|
|
if selected_index not in applied_set:
|
|
applied.append(selected_index)
|
|
applied_set.add(selected_index)
|
|
i += len(selected_placeholder)
|
|
continue
|
|
parts.append(text[i])
|
|
i += 1
|
|
|
|
if not applied:
|
|
return text, []
|
|
return "".join(parts), applied
|
|
|
|
|
|
def _substitution_replacements(
|
|
substitutions: list[dict[str, Any]], surface: str, content_type: str = ""
|
|
) -> list[tuple[str, str]]:
|
|
replacements: list[tuple[str, str]] = []
|
|
for substitution in substitutions:
|
|
placeholder = substitution.get("placeholder")
|
|
value = substitution.get("value")
|
|
surfaces = substitution.get("in") or []
|
|
if not placeholder or value is None or surface not in surfaces:
|
|
continue
|
|
replacements.append(
|
|
(
|
|
str(placeholder),
|
|
_encoded_substitution_value(str(value), surface, content_type),
|
|
)
|
|
)
|
|
return replacements
|
|
|
|
|
|
def _apply_path_query_substitutions(
|
|
flow: http.HTTPFlow,
|
|
substitutions: list[dict[str, Any]],
|
|
) -> list[str]:
|
|
raw_path = flow.request.path or "/"
|
|
path_part, query_part = _split_path_query(raw_path)
|
|
path_changed = False
|
|
query_changed = False
|
|
applied: list[str] = []
|
|
|
|
path_part, path_applied = _replace_literals_once(
|
|
path_part, _substitution_replacements(substitutions, "path")
|
|
)
|
|
if path_applied:
|
|
path_changed = True
|
|
applied.extend("path" for _ in path_applied)
|
|
if query_part is not None:
|
|
query_part, query_applied = _replace_literals_once(
|
|
query_part, _substitution_replacements(substitutions, "query")
|
|
)
|
|
if query_applied:
|
|
query_changed = True
|
|
applied.extend("query" for _ in query_applied)
|
|
|
|
if path_changed:
|
|
candidate_path = path_part
|
|
if query_part is not None:
|
|
candidate_path = f"{candidate_path}?{query_part}"
|
|
if _path_is_ambiguous(candidate_path):
|
|
flow.response = http.Response.make(
|
|
403,
|
|
b"request path contains ambiguous substituted segments\n",
|
|
{"content-type": "text/plain"},
|
|
)
|
|
ctx.log.warn(
|
|
"credential proxy: rejected request after path substitution: "
|
|
f"{flow.request.method} {_request_host(flow)} path=[REDACTED]"
|
|
)
|
|
return applied
|
|
|
|
if path_changed or query_changed:
|
|
flow.request.path = path_part if query_part is None else f"{path_part}?{query_part}"
|
|
return applied
|
|
|
|
|
|
def _apply_header_substitutions(
|
|
flow: http.HTTPFlow,
|
|
substitutions: list[dict[str, Any]],
|
|
) -> list[str]:
|
|
applied: list[str] = []
|
|
replacements = _substitution_replacements(substitutions, "header")
|
|
if not replacements:
|
|
return applied
|
|
for name, header_value in list(flow.request.headers.items()):
|
|
if name.lower() in HEADER_SUBSTITUTION_DENYLIST:
|
|
continue
|
|
updated, header_applied = _replace_literals_once(str(header_value), replacements)
|
|
if header_applied:
|
|
flow.request.headers[name] = updated
|
|
applied.extend("header" for _ in header_applied)
|
|
return applied
|
|
|
|
|
|
def _apply_body_substitutions(
|
|
flow: http.HTTPFlow,
|
|
substitutions: list[dict[str, Any]],
|
|
) -> list[str]:
|
|
body = _request_body_bytes(flow)
|
|
if body is None:
|
|
return []
|
|
|
|
content_encoding = flow.request.headers.get("content-encoding", "").strip().lower()
|
|
if content_encoding and content_encoding != "identity":
|
|
return []
|
|
|
|
content_type = flow.request.headers.get("content-type", "")
|
|
if content_type.split(";", 1)[0].strip().lower().startswith("multipart/"):
|
|
return []
|
|
|
|
try:
|
|
text = body.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return []
|
|
|
|
applied: list[str] = []
|
|
replacements = _substitution_replacements(substitutions, "body", content_type)
|
|
text, body_applied = _replace_literals_once(text, replacements)
|
|
applied.extend("body" for _ in body_applied)
|
|
|
|
if applied:
|
|
_set_request_body_bytes(flow, text.encode("utf-8"))
|
|
return applied
|
|
|
|
|
|
def _apply_substitutions(flow: http.HTTPFlow, binding: dict[str, Any]) -> list[str]:
|
|
substitutions = binding.get("substitutions") or []
|
|
if not substitutions:
|
|
return []
|
|
|
|
applied = []
|
|
applied.extend(_apply_path_query_substitutions(flow, substitutions))
|
|
if flow.response is not None and getattr(flow.response, "status_code", None) == 403:
|
|
return applied
|
|
applied.extend(_apply_header_substitutions(flow, substitutions))
|
|
applied.extend(_apply_body_substitutions(flow, substitutions))
|
|
return applied
|
|
|
|
|
|
def request(flow: http.HTTPFlow) -> None:
|
|
vault = _load_active_vault()
|
|
if vault is None:
|
|
return
|
|
|
|
# Reject requests with ambiguous path segments before credential injection.
|
|
# Raw dot-segments or encoded variants on the wire are not produced by
|
|
# legitimate HTTP clients and can trick prefix-based path matching into
|
|
# injecting credentials for a scope the canonical path does not belong to.
|
|
raw_path = flow.request.path or "/"
|
|
if _path_is_ambiguous(raw_path):
|
|
flow.response = http.Response.make(
|
|
403,
|
|
b"request path contains ambiguous segments\n",
|
|
{"content-type": "text/plain"},
|
|
)
|
|
ctx.log.warn(
|
|
"credential proxy: rejected request with ambiguous path: "
|
|
f"{flow.request.method} {_request_host(flow)}{_request_path(flow)}"
|
|
)
|
|
return
|
|
|
|
binding = _select_binding(flow, vault)
|
|
if not binding:
|
|
return
|
|
|
|
substituted_surfaces = _apply_substitutions(flow, binding)
|
|
if flow.response is not None and getattr(flow.response, "status_code", None) == 403:
|
|
return
|
|
|
|
injected_names: list[str] = []
|
|
for header in binding.get("headers") or []:
|
|
name = header.get("name")
|
|
value = header.get("value")
|
|
if not name or value is None:
|
|
continue
|
|
# mitmproxy Headers is case-insensitive; delete first to avoid duplicate
|
|
# effective header names before setting the credentialed value.
|
|
if name in flow.request.headers:
|
|
del flow.request.headers[name]
|
|
flow.request.headers[name] = value
|
|
injected_names.append(name)
|
|
|
|
if injected_names or substituted_surfaces:
|
|
flow.metadata[FLOW_REDACTIONS_KEY] = list(vault.redactions)
|
|
ctx.log.info(
|
|
"credential proxy: applied binding="
|
|
f"{binding.get('name')} revision={vault.revision} "
|
|
f"host={_request_host(flow)} method={flow.request.method} "
|
|
f"headers={','.join(injected_names)} "
|
|
f"substitutions={','.join(sorted(set(substituted_surfaces)))}"
|
|
)
|
|
elif binding.get("substitutions"):
|
|
ctx.log.info(
|
|
"credential proxy: substitution miss binding="
|
|
f"{binding.get('name')} revision={vault.revision} "
|
|
f"host={_request_host(flow)} method={flow.request.method}"
|
|
)
|
|
|
|
|
|
def responseheaders(flow: http.HTTPFlow) -> None:
|
|
if flow.response is None:
|
|
return
|
|
_redact_response_headers(flow)
|
|
content_type = flow.response.headers.get("content-type", "").lower()
|
|
transfer_encoding = flow.response.headers.get("transfer-encoding", "").lower()
|
|
if "text/event-stream" in content_type or "chunked" in transfer_encoding:
|
|
flow.response.stream = True
|
|
|
|
|
|
def _redact_response_headers(flow: http.HTTPFlow) -> None:
|
|
redactions = flow.metadata.get(FLOW_REDACTIONS_KEY, [])
|
|
if not redactions or flow.response is None:
|
|
return
|
|
for name, value in list(flow.response.headers.items()):
|
|
redacted = _redact_text(value, redactions)
|
|
if redacted != value:
|
|
flow.response.headers[name] = redacted
|
|
|
|
|
|
def _redact_text(text: str, values: list[str]) -> str:
|
|
out = text
|
|
for value in sorted({value for value in values if value}, key=len, reverse=True):
|
|
out = out.replace(value, "[REDACTED]")
|
|
return out
|