Skip to content
Merged
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
53 changes: 46 additions & 7 deletions Discovery/adr_discovery/redact/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"),
Expand Down Expand Up @@ -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
Expand All @@ -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("\\", "/")
Expand Down
23 changes: 23 additions & 0 deletions Discovery/adr_discovery/tests_unit/test_cross_cutting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down