From c3ad47742bd695a11f8d153afbdde22c29fed3e0 Mon Sep 17 00:00:00 2001 From: Pengyu Zhang Date: Sun, 30 Aug 2026 13:00:03 -0700 Subject: [PATCH] fix(discovery): redact joined credential arguments Handle joined short-option operands and credential-like key=value assignments while preserving noncredential assignments. Reported-by: Syed Anas Mohiuddin --- Discovery/adr_discovery/redact/rules.py | 53 ++++++++++++++++--- .../tests_unit/test_cross_cutting.py | 23 ++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/Discovery/adr_discovery/redact/rules.py b/Discovery/adr_discovery/redact/rules.py index 8581ae3..8833545 100644 --- a/Discovery/adr_discovery/redact/rules.py +++ b/Discovery/adr_discovery/redact/rules.py @@ -11,6 +11,7 @@ from __future__ import annotations +import re from urllib.parse import urlsplit, urlunsplit #: Flags whose *operand* is secret. The flag name survives; the value does not. @@ -24,6 +25,23 @@ } ) +#: Short options that accept their secret operand without a separator, such as +#: MySQL's ``-pPASSWORD`` and curl's ``-HAuthorization: ...``. This is +#: intentionally explicit: treating every short option as joined would redact +#: unrelated argv merely because it begins with the same character. +JOINED_SHORT_CREDENTIAL_FLAGS: tuple[str, ...] = ("-H", "-m", "-p") + +#: Credential-looking assignment keys can appear without a leading dash in +#: process argv (for example ``password=...`` or ``OPENAI_API_KEY=...``). +#: Boundaries keep ordinary keys such as ``profile`` and ``port`` intact. +CREDENTIAL_KEY_RE = re.compile( + r"(?:^|[_-])" + r"(?:api[_-]?key|access[_-]?token|auth|authorization|client[_-]?secret|" + r"password|passwd|secret|token)" + r"(?:$|[_-])", + re.IGNORECASE, +) + #: Environment variable names whose presence implies a credential of a kind. CREDENTIAL_ENV_PREFIXES: tuple[tuple[str, str], ...] = ( ("ANTHROPIC_", "anthropic"), @@ -90,8 +108,9 @@ def credential_kinds(names: tuple[str, ...]) -> tuple[str, ...]: def scrub_argv(argv: tuple[str, ...] | list[str]) -> tuple[str, ...]: """Keep every flag name and every non-secret operand. - Values of credential-bearing flags are replaced, in both the separated - (`--token X`) and joined (`--token=X`) forms. + Values of credential-bearing flags are replaced in separated + (``--token X``), assignment (``--token=X`` or ``password=X``), and joined + short-option (``-pX``) forms. """ out: list[str] = [] expect_value = False @@ -100,19 +119,39 @@ def scrub_argv(argv: tuple[str, ...] | list[str]) -> tuple[str, ...]: out.append(REDACTED) expect_value = False continue - if "=" in arg and arg.startswith("-"): + + if arg in CREDENTIAL_FLAGS: + out.append(arg) + expect_value = True + continue + + if "=" in arg: flag, _, _ = arg.partition("=") - if flag in CREDENTIAL_FLAGS: + if flag in CREDENTIAL_FLAGS or _is_credential_key(flag): out.append(f"{flag}={REDACTED}") continue - out.append(arg) + + joined_flag = next( + ( + flag + for flag in JOINED_SHORT_CREDENTIAL_FLAGS + if arg.startswith(flag) and len(arg) > len(flag) + ), + None, + ) + if joined_flag is not None: + out.append(f"{joined_flag}{REDACTED}") continue + out.append(arg) - if arg in CREDENTIAL_FLAGS: - expect_value = True return tuple(out) +def _is_credential_key(value: str) -> bool: + """Whether an assignment key denotes credential material.""" + return CREDENTIAL_KEY_RE.search(value.lstrip("-")) is not None + + def is_personal(resolved_path: str) -> bool: """Decided on the resolved target, never on the path handed in.""" probe = resolved_path.replace("\\", "/") diff --git a/Discovery/adr_discovery/tests_unit/test_cross_cutting.py b/Discovery/adr_discovery/tests_unit/test_cross_cutting.py index a6cdad2..ffdb389 100644 --- a/Discovery/adr_discovery/tests_unit/test_cross_cutting.py +++ b/Discovery/adr_discovery/tests_unit/test_cross_cutting.py @@ -83,6 +83,29 @@ def test_uc2_02_a_flag_name_survives_redaction(): assert "SECRET" not in scrubbed +def test_uc2_02a_joined_short_credential_operands_are_redacted(): + assert redact.scrub_argv(("mysql", "-uroot", "-pSuperSecret123", "mydb")) == ( + "mysql", "-uroot", f"-p{redact.REDACTED}", "mydb", + ) + assert redact.scrub_argv(("curl", "-HAuthorization: Bearer secret")) == ( + "curl", f"-H{redact.REDACTED}", + ) + + +def test_uc2_02b_credential_assignments_without_flags_are_redacted(): + assert redact.scrub_argv(("agent", "password=hunter2")) == ( + "agent", f"password={redact.REDACTED}", + ) + assert redact.scrub_argv(("agent", "OPENAI_API_KEY=sk-secret")) == ( + "agent", f"OPENAI_API_KEY={redact.REDACTED}", + ) + + +def test_uc2_02c_noncredential_assignments_are_preserved(): + assert redact.scrub_argv(("server", "--port=8080")) == ("server", "--port=8080") + assert redact.scrub_argv(("agent", "profile=production")) == ("agent", "profile=production") + + def test_uc2_04_explain_names_every_rule_it_enforces(): lines = redact.explain()