Skip to content
Open
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
31 changes: 31 additions & 0 deletions docksec/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<secret>
# ConnectionStrings__Default=Server=db;User Id=sa;Password=<secret>;
# 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<pre>[?&;,\"']\s*)"
r"(?P<key>password|passwd|pwd|secret|token|api[_-]?key|apikey|"
r"access[_-]?key|private[_-]?key|credential|auth)"
r"(?P<sep>\s*[=:]\s*)"
r"(?P<val>[^&;,\s\"']+)",
re.IGNORECASE,
)


def _is_placeholder(value: str) -> bool:
"""Interpolations and empty values carry no secret material."""
Expand Down Expand Up @@ -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)
Expand Down
46 changes: 46 additions & 0 deletions tests/test_redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading