{"repo":"NousResearch/hermes-agent","public_id":"16413","display_label":"Redaction fix for lowercase/dotted config secret keys","summary":"These issues and PRs address a gap in the redaction system that fails to obscure passwords and secrets in lowercase or dot-separated configuration keys such as 'spring.datasource.password'. Multiple fix attempts target both the redaction logic and terminal output handling.","subsystem":"agent","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53590,"merged_at":"2026-06-27T11:43:29+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-27T11:43:29+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":16413,"pr_number":53590,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-27T11:43:29+00:00"}],"members":[{"kind":"issue","number":16413,"title":"Security: Redaction system fails to obscure passwords in lowercase/dotted config keys (e.g., spring.datasource.password)","url":null,"state":"closed"},{"kind":"pr","number":16430,"title":"fix(agent): redact lowercase/dotted config keys in terminal output (#16413)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"f98b5d00a","number":0}],"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":21008,"title":"fix(redact): cover lowercase config secret keys","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"190e1ffac976ee5fc41c9f1845ba8fd886a827b1","number":0},{"kind":"pr","value":"#53590","number":53590},{"kind":"pr","value":"#16413","number":16413}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":53590,"title":"fix(redact): mask passwords in lowercase/dotted config keys (#16413)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/redact.py","tests/agent/test_redact.py"],"fix_hunks":[{"file":"agent/redact.py","hunks":["@@ -107,12 +107,60 @@\n     r\"ntn_[A-Za-z0-9]{10,}\",            # Notion internal integration token\n ]\n \n-# ENV assignment patterns: KEY=value where KEY contains a secret-like name\n+# ENV assignment patterns: KEY=value where KEY contains a secret-like name.\n+# Uppercase keys tolerate spaces around \"=\" (e.g. ``FOO_SECRET = [redacted:secret-assignment] because\n+# an all-caps key is almost never prose/code.\n _SECRET_ENV_NAMES = r\"(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)\"\n _ENV_ASSIGN_RE = re.compile(\n     rf\"([A-Z0-9_]{{0,50}}{_SECRET_ENV_NAMES}[A-Z0-9_]{{0,50}})\\s*=\\s*(['\\\"]?)(\\S+)\\2\",\n )\n \n+# Lowercase / dotted / hyphenated config keys from config files\n+# (application.properties, .env, YAML-ish dumps): ``spring.datasource.password=[redacted:secret-assignment]\n+# ``app.api.key=xyz``, ``password=[redacted:secret-assignment] The uppercase _ENV_ASSIGN_RE above\n+# never matched these, so config-file passwords leaked verbatim (issue #16413).\n+#\n+# These run only in a config-file context, NOT in prose, code, or URLs — three\n+# carve-outs preserved from the original design (#4367 + the documented\n+# web-URL passthrough below):\n+#   1. The value is bounded by ``[^\\s&]`` (stops at whitespace AND ``&``) so\n+#      form-urlencoded bodies are handled pair-by-pair (by _redact_form_body),\n+#      not greedily swallowed.\n+#   2. _CFG_DOTTED_RE only matches when the key is NAMESPACED (contains a dot),\n+#      which is unambiguously a config key — never a prose word.\n+#   3. _CFG_ANCHORED_RE matches a bare secret-word key only at line start\n+#      (optionally after ``export``), so conversational ``I have password=foo``\n+#      mid-sentence is left alone.\n+# The colon-form URL guard (skip when ``://`` present) lives at the call site.\n+_SECRET_CFG_NAMES = r\"(?:api[ _.\\-]?key|token|secret|passwd|password|credential|auth)\"\n+_CFG_VALUE = r\"(['\\\"]?)([^\\s&]+?)\\2(?=[\\s&]|$)\"\n+# Namespaced (dotted) key: the secret word may sit anywhere in a dotted path.\n+_CFG_DOTTED_RE = re.compile(\n+    rf\"((?:[A-Za-z0-9_\\-]+\\.)+[A-Za-z0-9_.\\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\\-]*\"\n+    rf\"|[A-Za-z0-9_.\\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\\-]*\\.[A-Za-z0-9_.\\-]+)\"\n+    rf\"={_CFG_VALUE}\",\n+    re.IGNORECASE,\n+)\n+# Line-anchored bare key: ``password=…`` / ``export api_key=…`` at start of line.\n+_CFG_ANCHORED_RE = re.compile(\n+    rf\"(^[ \\t]*(?:export[ \\t]+)?[A-Za-z0-9_\\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_\\-]*)={_CFG_VALUE}\",\n+    re.IGNORECASE | re.MULTILINE,\n+)\n+\n+# Unquoted YAML / colon config (e.g. ``password: [redacted:secret-assignment]\n+# ``spring.datasource.password: [redacted:secret-assignment] The secret keyword must be part of\n+# the KEY (anchored to the start of the line/indent), and the value is a single\n+# whitespace-free token — so prose like ``note: secret meeting`` (keyword in the\n+# value) and ``error: token expired`` are left alone. Bare ``auth`` is excluded\n+# from the key set so ``Authorization:`` / ``author:`` don't match (the former\n+# is masked by _AUTH_HEADER_RE); ``auth_token``/``auth-token`` still match via\n+# the ``token`` keyword. Quoted values defer to _JSON_FIELD_RE via the lookahead.\n+_YAML_CFG_NAMES = r\"(?:api[ _.\\-]?key|token|secret|passwd|password|credential)\"\n+_YAML_ASSIGN_RE = re.compile(\n+    rf\"(^[ \\t]*[A-Za-z0-9_.\\-]*{_YAML_CFG_NAMES}[A-Za-z0-9_.\\-]*)(:[ \\t]*)(?!['\\\"])([^\\s&]+)\",\n+    re.IGNORECASE | re.MULTILINE,\n+)\n+\n # JSON field patterns: \"apiKey\": \"value\", \"token\": \"value\", etc.\n _JSON_KEY_NAMES = r\"(?:api_?[Kk]ey|token|secret|password|access_token|refresh_token|auth_token|bearer|secret_value|raw_secret|secret_input|key_material)\"\n _JSON_FIELD_RE = re.compile(","@@ -382,6 +430,13 @@ def _redact_env(m):\n                 name, quote, value = m.group(1), m.group(2), m.group(3)\n                 return f\"{name}={quote}{_mask_token(value)}{quote}\"\n             text = _ENV_ASSIGN_RE.sub(_redact_env, text)\n+            # Lowercase/dotted config keys (issue #16413). Skip URLs entirely —\n+            # web-URL query params are intentionally passed through (see note\n+            # near the bottom of this function); _DB_CONNSTR_RE still guards\n+            # connection-string passwords.\n+            if \"://\" not in text:\n+                text = _CFG_DOTTED_RE.sub(_redact_env, text)\n+                text = _CFG_ANCHORED_RE.sub(_redact_env, text)\n \n         # JSON fields: \"apiKey\": \"***\"  (skip for code files — false positives)\n         if \":\" in text and '\"' in text:","@@ -390,6 +445,15 @@ def _redact_json(m):\n                 return f'{key}: \"{_mask_token(value)}\"'\n             text = _JSON_FIELD_RE.sub(_redact_json, text)\n \n+        # Unquoted YAML / colon config: password: ***  (after JSON so quoted\n+        # values are handled there; the lookahead in _YAML_ASSIGN_RE skips\n+        # quotes). Skip URLs — web-URL query params pass through by design.\n+        if \":\" in text and \"://\" not in text:\n+            def _redact_yaml(m):\n+                key, sep, value = m.group(1), m.group(2), m.group(3)\n+                return f\"{key}{sep}{_mask_token(value)}\"\n+            text = _YAML_ASSIGN_RE.sub(_redact_yaml, text)\n+\n     # Authorization headers — _AUTH_HEADER_RE matches any scheme after\n     # \"[Proxy-]Authorization:\" case-insensitively, so \"uthorization\" is the\n     # cheapest substring gate that covers every casing without a casefold()."]},{"file":"tests/agent/test_redact.py","hunks":["@@ -504,6 +504,81 @@ def test_multiline_text_not_form(self):\n         assert \"first=1\" in redact_sensitive_text(text)\n \n \n+class TestLowercaseDottedConfigKeys:\n+    \"\"\"Issue #16413 — config-file passwords in lowercase/dotted/colon keys\n+    must be redacted. The uppercase _ENV_ASSIGN_RE missed these, leaking\n+    `spring.datasource.password=...` and `password: ...` from `cat`'d config\n+    files. Carve-outs: prose, code (#4367), and web URLs are left untouched.\n+    \"\"\"\n+\n+    def test_spring_dotted_password_assignment(self):\n+        text = \"spring.datasource.password=[redacted:secret-assignment]\"\n+        result = redact_sensitive_text(text)\n+        assert \"Sup3rS3cret!\" not in result\n+        assert \"spring.datasource.password=\" in result\n+\n+    def test_dotted_api_key_split_keyword(self):\n+        # 'api.key' splits the keyword across a dot — must still match.\n+        text = \"app.api.key=ak_live_998877\"\n+        result = redact_sensitive_text(text)\n+        assert \"ak_live_998877\" not in result\n+        assert \"app.api.key=\" in result\n+\n+    def test_bare_lowercase_password_at_line_start(self):\n+        text = \"password=[redacted:secret-assignment]\"\n+        result = redact_sensitive_text(text)\n+        assert \"mysecretvalue123\" not in result\n+\n+    def test_quoted_lowercase_value(self):\n+        text = \"password='[redacted:secret-assignment]'\"\n+        result = redact_sensitive_text(text)\n+        assert \"mysecretvalue123\" not in result\n+\n+    def test_yaml_unquoted_password(self):\n+        text = \"password: [redacted:secret-assignment]\"\n+        result = redact_sensitive_text(text)\n+        assert \"Sup3rS3cret!\" not in result\n+        assert \"password:\" in result\n+\n+    def test_yaml_indented_dotted(self):\n+        text = \"spring:\\n  datasource:\\n    password: [redacted:secret-assignment]\"\n+        result = redact_sensitive_text(text)\n+        assert \"hunter2pass\" not in result\n+\n+    def test_properties_file_dump(self):\n+        text = (\n+            \"server.port=8080\\n\"\n+            \"spring.datasource.username=admin\\n\"\n+            \"spring.datasource.password=[redacted:secret-assignment]\"\n+            \"logging.level.root=INFO\"\n+        )\n+        result = redact_sensitive_text(text)\n+        assert \"Sup3rS3cret!\" not in result\n+        assert \"server.port=8080\" in result  # non-secret keys preserved\n+        assert \"username=admin\" in result\n+\n+    # --- carve-outs: must NOT redact ---\n+\n+    def test_prose_mid_sentence_password_unchanged(self):\n+        # Not line-anchored, not dotted → conversational text, leave alone.\n+        text = \"I have password=foo and other things\"\n+        assert redact_sensitive_text(text) == text\n+\n+    def test_lowercase_code_assignment_unchanged(self):\n+        # #4367 regression — spaces around '=' in code.\n+        text = \"const secret = await fetchSecret();\"\n+        assert redact_sensitive_text(text) == text\n+\n+    def test_url_query_param_passes_through(self):\n+        # Web URLs are intentionally hands-off (documented design).\n+        text = \"https://example.com/api?password=[redacted:secret-assignment]\"\n+        assert redact_sensitive_text(text) == text\n+\n+    def test_prose_keyword_in_value_unchanged(self):\n+        text = \"note: secret meeting at noon\"\n+        assert redact_sensitive_text(text) == text\n+\n+\n class TestXaiToken:\n     KEY = \"xai-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu\"\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}