refactor(redact): consolidate CDP-URL log redaction into one chokepoint

The session-log fix (browser_tool._sanitize_url_for_logs) and the
supervisor attach-timeout fix (CDPSupervisor.start) both composed the
same three redactors (redact_sensitive_text -> _redact_url_query_params
-> _redact_url_userinfo) to mask CDP endpoint credentials. Two copies of
one policy drift: tune one site (e.g. add fragment masking) and the other
silently re-leaks.

Promote that composition to a single public helper redact_cdp_url() in
agent/redact.py -- the one place the CDP-URL redaction policy lives -- and
route both call sites through it (_sanitize_url_for_logs becomes a thin
wrapper; the supervisor imports the helper instead of re-composing the
private redactors). Add direct unit tests for the seam covering query
tokens, multiple credentials, userinfo passwords, plain-URL passthrough,
non-string/exception coercion, and None.

No behavior change at the call sites; both leak paths remain closed.
This commit is contained in:
kshitijk4poor 2026-07-01 13:30:36 +05:30 committed by kshitij
parent 265da9cadb
commit c626dded13
4 changed files with 76 additions and 26 deletions

View file

@ -400,6 +400,30 @@ def _redact_url_userinfo(text: str) -> str:
)
def redact_cdp_url(value: object) -> str:
"""Mask secrets in a CDP/browser endpoint URL before it is logged.
The global ``redact_sensitive_text`` deliberately passes web-URL query
params and ``user:pass@`` userinfo through unmasked (OAuth callbacks,
magic-link / pre-signed URLs the agent is meant to follow -- see the
web-URL note above). CDP discovery endpoints are NOT such a workflow:
their query-string tokens and userinfo passwords are pure credentials
that must never reach the logs. So for CDP URLs we opt INTO the two URL
redactors that the global pass leaves off.
This is the single source of truth for CDP-URL log redaction. Every site
that emits a resolved CDP URL to a log or exception message -- the browser
tool's session/discovery logs and the supervisor's attach-timeout error --
routes through here so the policy can never drift between call sites.
"""
text = redact_sensitive_text("" if value is None else str(value))
if not text:
return text
text = _redact_url_query_params(text)
text = _redact_url_userinfo(text)
return text
def _redact_http_request_target_query_params(text: str) -> str:
"""Redact sensitive query params in HTTP access-log request targets."""
def _sub(m: re.Match) -> str:

View file

@ -4,7 +4,7 @@ import logging
import pytest
from agent.redact import redact_sensitive_text, RedactingFormatter
from agent.redact import redact_cdp_url, redact_sensitive_text, RedactingFormatter
@pytest.fixture(autouse=True)
@ -908,3 +908,45 @@ class TestFireworksToken:
def test_prefix_visible_in_masked_output(self):
result = redact_sensitive_text(self.KEY, force=True)
assert result.startswith("fw_AA")
class TestRedactCdpUrl:
"""redact_cdp_url() is the single chokepoint for CDP endpoint log redaction.
Unlike the global pass (which deliberately lets web-URL query params and
userinfo through for OAuth/magic-link workflows), CDP endpoint credentials
are pure secrets and must always be masked. Both the browser tool's
session/discovery logs and the supervisor's attach-timeout error route
through this helper.
"""
def test_masks_query_string_token(self):
url = "wss://cdp.example/devtools/browser/abc?token=super-secret-999"
out = redact_cdp_url(url)
assert "super-secret-999" not in out
assert "token=***" in out
def test_masks_multiple_query_credentials(self):
url = "wss://provider.example/session?token=aaa-secret&apikey=bbb-secret"
out = redact_cdp_url(url)
assert "aaa-secret" not in out
assert "bbb-secret" not in out
def test_masks_userinfo_password(self):
url = "wss://user:p4ssw0rd@cdp.example/devtools/browser/x"
out = redact_cdp_url(url)
assert "p4ssw0rd" not in out
assert "user:***@" in out
def test_plain_url_passes_through(self):
url = "ws://localhost:9222/devtools/browser/abc123"
assert redact_cdp_url(url) == url
def test_non_string_input_coerced(self):
# Exceptions and other objects are stringified, not crashed on.
exc = RuntimeError("connect failed: wss://h/x?token=leak-me")
out = redact_cdp_url(exc)
assert "leak-me" not in out
def test_none_returns_empty(self):
assert redact_cdp_url(None) == ""

View file

@ -342,12 +342,8 @@ class CDPSupervisor:
if not self._ready_event.wait(timeout=timeout):
self.stop()
try:
from agent.redact import (
_redact_url_query_params,
_redact_url_userinfo,
redact_sensitive_text,
)
_safe_url = _redact_url_userinfo(_redact_url_query_params(redact_sensitive_text(self.cdp_url)))
from agent.redact import redact_cdp_url
_safe_url = redact_cdp_url(self.cdp_url)
except Exception:
_safe_url = "<cdp_url redacted>"
raise TimeoutError(

View file

@ -65,11 +65,7 @@ import requests
from typing import Dict, Any, Optional, List, Tuple, Union
from pathlib import Path
from agent.auxiliary_client import call_llm
from agent.redact import (
redact_sensitive_text,
_redact_url_query_params,
_redact_url_userinfo,
)
from agent.redact import redact_cdp_url
from hermes_constants import agent_browser_runnable, get_hermes_home
from utils import env_int, is_truthy_value
from hermes_cli.config import DEFAULT_CONFIG, cfg_get
@ -244,21 +240,13 @@ _command_timeout_resolved = False
def _sanitize_url_for_logs(value: object) -> str:
"""Mask secrets in logged browser endpoint URLs and URL-like errors.
The global ``redact_sensitive_text`` deliberately passes web-URL query
params and ``user:pass@`` userinfo through unmasked (OAuth callbacks,
magic-link / pre-signed URLs the agent is meant to follow see the
web-URL note in ``agent/redact.py``). CDP discovery endpoints are NOT
such a workflow: their query-string tokens and userinfo passwords are
pure credentials that must never reach the logs. So at these log sites
we opt INTO the URL redactors that the global pass leaves off, reusing
the shared ``redact.py`` helpers rather than a second regex.
Thin wrapper over :func:`agent.redact.redact_cdp_url`, which is the single
source of truth for CDP-URL log redaction. Kept as a local name because
several browser-tool log sites reference it; the redaction policy itself
lives once in ``redact.py`` so the browser tool and the CDP supervisor
cannot drift apart.
"""
text = redact_sensitive_text(value)
if not text:
return text
text = _redact_url_query_params(text)
text = _redact_url_userinfo(text)
return text
return redact_cdp_url(value)
def _get_command_timeout() -> int: