diff --git a/openhands-sdk/openhands/sdk/utils/redact.py b/openhands-sdk/openhands/sdk/utils/redact.py index d204426e46..548b7ca72b 100644 --- a/openhands-sdk/openhands/sdk/utils/redact.py +++ b/openhands-sdk/openhands/sdk/utils/redact.py @@ -302,18 +302,25 @@ def redact_text_secrets(text: str) -> str: text = re.sub(r"api_key='[^']*'", "api_key=''", text) text = re.sub(r'api_key="[^"]*"', 'api_key=""', text) - # Dict entries with sensitive key names (case-insensitive, like is_secret_key) + # Dict entries with sensitive key names (case-insensitive, like is_secret_key). + # The keyword must be an underscore-separated component of the key name + # (e.g. api_key, MY_SECRET) or a PascalCase/camelCase component (e.g. + # UserPassword, apiKey). A bare substring match is NOT enough — otherwise + # keys like "monkey", "secretary", "keyboard", or "tokenizer" would be + # over-redacted. + _dict_key_single = ( + r"(?i:(?:[A-Za-z0-9]+_)*(?:KEY|SECRET|TOKEN|PASSWORD)(?:_[A-Za-z0-9]+)*)" + ) + _dict_key_camel = r"[A-Za-z]*?(?:Key|Secret|Token|Password)[A-Za-z]*" text = re.sub( - r"('[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD)[A-Z_]*':\s*')[^']*(')", + rf"('(?:{_dict_key_single}|{_dict_key_camel})':\s*')[^']*(')", r"\g<1>\2", text, - flags=re.IGNORECASE, ) text = re.sub( - r'("[A-Z_]*(?:KEY|SECRET|TOKEN|PASSWORD)[A-Z_]*":\s*")[^"]*(")', + rf'("(?:{_dict_key_single}|{_dict_key_camel})":\s*")[^"]*(")', r"\g<1>\2", text, - flags=re.IGNORECASE, ) # URL query params diff --git a/tests/sdk/utils/test_redact.py b/tests/sdk/utils/test_redact.py index ff56e6432d..439a2809ac 100644 --- a/tests/sdk/utils/test_redact.py +++ b/tests/sdk/utils/test_redact.py @@ -284,3 +284,17 @@ def test_uppercase_dict_keys_still_redacted(self): def test_non_sensitive_entries_unchanged(self): text = "{'name': 'alice', 'path': '/tmp/x'}" assert redact_text_secrets(text) == text + + def test_does_not_over_redact_keys_containing_keyword_substrings(self): + """Keys that merely contain a sensitive keyword as a substring + (without underscore separation) must not be over-redacted. + + Regression: ``re.IGNORECASE`` on ``[A-Z_]*KEY[A-Z_]*`` matches + ``monkey``, ``donkey``, ``keyboard``, ``secretary``, etc. + """ + text = ( + "{'monkey': 'banana', 'donkey': 'farm', " + "'keyboard': 'mechanical', 'secretary': 'Jane Doe', " + "'keyword': 'search-term', 'tokenizer': 'bert-base'}" + ) + assert redact_text_secrets(text) == text