diff --git a/docksec/redact.py b/docksec/redact.py index b30e14f..32e9c0d 100644 --- a/docksec/redact.py +++ b/docksec/redact.py @@ -65,6 +65,25 @@ re.IGNORECASE, ) +# Secret-looking assignment nested INSIDE a larger value, after a compound +# delimiter (?, &, ;, comma, or a quote). This is the same threat as URL +# userinfo credentials, but in the query-string and connection-string forms: +# SPRING_DATASOURCE_URL=jdbc:postgresql://db/app?user=sa&password= +# ConnectionStrings__Default=Server=db;User Id=sa;Password=; +# Here the outer key (SPRING_DATASOURCE_URL, ConnectionStrings__Default) is not +# secret-looking, so the KEY=value passes above skip the whole field and the +# embedded secret would otherwise leave the machine. The leading delimiter is +# required so this never double-handles a top-level, space-delimited assignment +# already covered above. Only the value is masked; the key stays visible. +_EMBEDDED_SECRET_ASSIGN = re.compile( + r"(?P
[?&;,\"']\s*)"
+    r"(?Ppassword|passwd|pwd|secret|token|api[_-]?key|apikey|"
+    r"access[_-]?key|private[_-]?key|credential|auth)"
+    r"(?P\s*[=:]\s*)"
+    r"(?P[^&;,\s\"']+)",
+    re.IGNORECASE,
+)
+
 
 def _is_placeholder(value: str) -> bool:
     """Interpolations and empty values carry no secret material."""
@@ -124,6 +143,18 @@ def _sub_url(match: re.Match) -> str:
 
         line = _URL_CREDENTIALS.sub(_sub_url, line)
 
+        # Secret assignments nested inside a compound value (query-string or
+        # connection-string fields whose outer key is not itself secret-looking).
+        def _sub_embedded(match: re.Match) -> str:
+            nonlocal count
+            val = match.group("val")
+            if val == REDACTED or _is_placeholder(val):
+                return match.group(0)
+            count += 1
+            return f"{match.group('pre')}{match.group('key')}{match.group('sep')}{REDACTED}"
+
+        line = _EMBEDDED_SECRET_ASSIGN.sub(_sub_embedded, line)
+
         # Value-shaped secrets (AWS keys, PATs, JWTs, ...) regardless of key name.
         for pattern in _SECRET_VALUE_PATTERNS:
             line, n = pattern.subn(REDACTED, line)
diff --git a/tests/test_redact.py b/tests/test_redact.py
index 39a9210..fee3e98 100644
--- a/tests/test_redact.py
+++ b/tests/test_redact.py
@@ -96,3 +96,49 @@ def test_no_secrets_no_change():
     redacted, count = redact_content(content)
     assert count == 0
     assert redacted == content
+
+
+def test_redacts_password_in_url_query_string():
+    # Credentials in URL query params (JDBC / Spring datasource URLs) are the
+    # same threat as userinfo creds, but the outer key is not secret-looking.
+    content = (
+        "ENV SPRING_DATASOURCE_URL="
+        "jdbc:postgresql://db:5432/app?user=sa&password=Sup3rSecret\n"
+    )
+    redacted, count = redact_content(content)
+    assert count == 1
+    assert "Sup3rSecret" not in redacted
+    # Non-secret parts of the URL stay visible so the model can still flag it.
+    assert "jdbc:postgresql://db:5432/app?user=sa" in redacted
+
+
+def test_redacts_password_in_connection_string_fields():
+    # Semicolon-delimited connection strings (.NET/JDBC) embed the password in
+    # a field whose outer key (ConnectionStrings__Default) is not secret-looking.
+    content = (
+        "ENV ConnectionStrings__Default="
+        "Server=db;Database=app;User Id=sa;Password=P@ssw0rd123;\n"
+        '      - "CS=Server=db;Password=QuotedSecret999;"\n'
+    )
+    redacted, count = redact_content(content)
+    assert count == 2
+    for secret in ("P@ssw0rd123", "QuotedSecret999"):
+        assert secret not in redacted
+    # Host/user context is preserved.
+    assert "Server=db" in redacted and "User Id=sa" in redacted
+
+
+def test_embedded_pass_leaves_interpolations_and_plain_env_alone():
+    # The embedded pass must not fire on interpolations or double-count a
+    # top-level, space-delimited secret assignment already handled above.
+    content = (
+        "ENV DB_PASSWORD=${DB_PASSWORD}\n"
+        "ENV A=1 SECRET_KEY=s3cr3t B=2\n"
+        "ENV JDBC=jdbc:postgresql://db/app?password=${DB_PASSWORD}\n"
+    )
+    redacted, count = redact_content(content)
+    # Only SECRET_KEY on line 2 is a real secret; interpolations are untouched.
+    assert count == 1
+    assert "s3cr3t" not in redacted
+    assert "${DB_PASSWORD}" in redacted
+    assert "A=1" in redacted and "B=2" in redacted