From 718c56a4dbd004bac4ecbfc41f35f5a75d0ce548 Mon Sep 17 00:00:00 2001 From: Mrx Date: Tue, 15 Sep 2026 02:50:11 +0530 Subject: [PATCH] fix(redact): mask passwords embedded in connection-string URLs Keys like DATABASE_URL, REDIS_URL, MONGO_URI, AMQP_URL and SMTP_URL do not match the secret-key list, and the password inside a scheme://user:password@host URL matches none of the value-shaped patterns, so it was sent to the LLM in clear text. Add a userinfo pattern that masks only the password, keeping the scheme, user, and host visible so the model can still flag the credential. Adds tests covering the leak and a no-false-positive case for credential-free URLs. Closes #168 --- docksec/redact.py | 41 ++++++++++++++++++----- tests/test_redact.py | 79 +++++++++++++++++++++++++++++--------------- 2 files changed, 85 insertions(+), 35 deletions(-) diff --git a/docksec/redact.py b/docksec/redact.py index 02e752c..b30e14f 100644 --- a/docksec/redact.py +++ b/docksec/redact.py @@ -25,12 +25,12 @@ # Values that look like secret material regardless of the key name. _SECRET_VALUE_PATTERNS = [ - re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key id - re.compile(r"ghp_[A-Za-z0-9]{36,}"), # GitHub personal access token - re.compile(r"gho_[A-Za-z0-9]{36,}"), # GitHub OAuth token - re.compile(r"github_pat_[A-Za-z0-9_]{22,}"), # GitHub fine-grained PAT - re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack token - re.compile(r"sk-[A-Za-z0-9_-]{20,}"), # OpenAI-style API key + re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key id + re.compile(r"ghp_[A-Za-z0-9]{36,}"), # GitHub personal access token + re.compile(r"gho_[A-Za-z0-9]{36,}"), # GitHub OAuth token + re.compile(r"github_pat_[A-Za-z0-9_]{22,}"), # GitHub fine-grained PAT + re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack token + re.compile(r"sk-[A-Za-z0-9_-]{20,}"), # OpenAI-style API key re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}"), # JWT ] @@ -39,6 +39,15 @@ re.DOTALL, ) +# Credentials embedded in a URL userinfo: scheme://user:password@host. Common in +# DB and broker URLs (DATABASE_URL, REDIS_URL, AMQP_URL, MONGO_URI, ...) whose +# key names do not look secret, so the password would otherwise slip through +# both the key-based and the value-shaped checks. Only the password is masked; +# the scheme, user, and host stay visible so the model can still flag it. +_URL_CREDENTIALS = re.compile( + r"(?P[a-zA-Z][a-zA-Z0-9+.-]*://[^:@/\s]*:)(?P[^@/\s]+)(?P@)" +) + # KEY=value pairs (Dockerfile ENV/ARG, compose list-style environment, .env # lines). Values may be quoted; interpolations like ${VAR} are left alone. _ASSIGN_EQ = re.compile( @@ -89,16 +98,32 @@ def _sub_eq(match: re.Match) -> str: if line == original: m = _ASSIGN_COLON.match(line) - if m and _SECRET_KEY.search(m.group("key")) and not _is_placeholder(m.group("val")): + if ( + m + and _SECRET_KEY.search(m.group("key")) + and not _is_placeholder(m.group("val")) + ): count += 1 line = f"{m.group('lead')}{m.group('key')}: {REDACTED}" if line == original: m = _ENV_SPACE.match(line) - if m and _SECRET_KEY.search(m.group("key")) and not _is_placeholder(m.group("val")): + if ( + m + and _SECRET_KEY.search(m.group("key")) + and not _is_placeholder(m.group("val")) + ): count += 1 line = f"{m.group('lead')}{m.group('key')} {REDACTED}" + # Passwords embedded in URL userinfo (scheme://user:password@host). + def _sub_url(match: re.Match) -> str: + nonlocal count + count += 1 + return f"{match.group('prefix')}{REDACTED}{match.group('at')}" + + line = _URL_CREDENTIALS.sub(_sub_url, 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 64ff5a9..39a9210 100644 --- a/tests/test_redact.py +++ b/tests/test_redact.py @@ -1,73 +1,98 @@ -from docksec.redact import redact_content, REDACTED +from docksec.redact import REDACTED, redact_content def test_redacts_dockerfile_env_equals(): - content = 'FROM python:3.12\nENV DB_PASSWORD=hunter2\nENV APP_NAME=myapp\n' + content = "FROM python:3.12\nENV DB_PASSWORD=hunter2\nENV APP_NAME=myapp\n" redacted, count = redact_content(content) assert count == 1 - assert 'hunter2' not in redacted - assert f'DB_PASSWORD={REDACTED}' in redacted - assert 'APP_NAME=myapp' in redacted + assert "hunter2" not in redacted + assert f"DB_PASSWORD={REDACTED}" in redacted + assert "APP_NAME=myapp" in redacted def test_redacts_dockerfile_env_space_form(): - content = 'ENV API_KEY abc123secretvalue\n' + content = "ENV API_KEY abc123secretvalue\n" redacted, count = redact_content(content) assert count == 1 - assert 'abc123secretvalue' not in redacted - assert 'API_KEY' in redacted + assert "abc123secretvalue" not in redacted + assert "API_KEY" in redacted def test_redacts_arg_and_multiple_pairs(): - content = 'ARG GITHUB_TOKEN=ghx123\nENV A=1 SECRET_KEY=s3cr3t B=2\n' + content = "ARG GITHUB_TOKEN=ghx123\nENV A=1 SECRET_KEY=s3cr3t B=2\n" redacted, count = redact_content(content) assert count == 2 - assert 'ghx123' not in redacted - assert 's3cr3t' not in redacted - assert 'A=1' in redacted and 'B=2' in redacted + assert "ghx123" not in redacted + assert "s3cr3t" not in redacted + assert "A=1" in redacted and "B=2" in redacted def test_redacts_compose_environment_styles(): content = ( - 'services:\n' - ' db:\n' - ' environment:\n' - ' - MYSQL_ROOT_PASSWORD=secret\n' - ' POSTGRES_PASSWORD: alsosecret\n' + "services:\n" + " db:\n" + " environment:\n" + " - MYSQL_ROOT_PASSWORD=secret\n" + " POSTGRES_PASSWORD: alsosecret\n" ) redacted, count = redact_content(content) assert count == 2 - assert 'secret' not in redacted.replace(REDACTED, '') + assert "secret" not in redacted.replace(REDACTED, "") def test_leaves_interpolations_alone(): - content = 'ENV DB_PASSWORD=${DB_PASSWORD}\n' + content = "ENV DB_PASSWORD=${DB_PASSWORD}\n" redacted, count = redact_content(content) assert count == 0 assert redacted == content def test_redacts_value_shaped_secrets_regardless_of_key(): - content = 'RUN aws configure set aws_access_key_id AKIAIOSFODNN7EXAMPLE\n' + content = "RUN aws configure set aws_access_key_id AKIAIOSFODNN7EXAMPLE\n" redacted, count = redact_content(content) assert count >= 1 - assert 'AKIAIOSFODNN7EXAMPLE' not in redacted + assert "AKIAIOSFODNN7EXAMPLE" not in redacted def test_redacts_private_key_block(): content = ( - 'COPY key.pem /app\n' - '-----BEGIN RSA PRIVATE KEY-----\n' - 'MIIEpAIBAAKCAQEA\n' - '-----END RSA PRIVATE KEY-----\n' + "COPY key.pem /app\n" + "-----BEGIN RSA PRIVATE KEY-----\n" + "MIIEpAIBAAKCAQEA\n" + "-----END RSA PRIVATE KEY-----\n" ) redacted, count = redact_content(content) assert count == 1 - assert 'MIIEpAIBAAKCAQEA' not in redacted + assert "MIIEpAIBAAKCAQEA" not in redacted + + +def test_redacts_password_in_url_credentials(): + content = ( + "ENV DATABASE_URL=postgres://admin:s3cr3tPass@db:5432/app\n" + "ENV REDIS_URL=redis://:mypassword@cache:6379/0\n" + ' - "AMQP_URL=amqp://user:rabbitpw@broker:5672/"\n' + "ENV MONGO_URI mongodb://root:supersecret@mongo:27017\n" + ) + redacted, count = redact_content(content) + assert count == 4 + for secret in ("s3cr3tPass", "mypassword", "rabbitpw", "supersecret"): + assert secret not in redacted + # Scheme, user, and host stay visible so the model can still flag it. + assert f"postgres://admin:{REDACTED}@db:5432/app" in redacted + assert f"redis://:{REDACTED}@cache:6379/0" in redacted + + +def test_leaves_url_without_password_alone(): + content = ( + "ENV APP_URL=https://example.com/health\n" "ENV HOST=postgres://db:5432/app\n" + ) + redacted, count = redact_content(content) + assert count == 0 + assert redacted == content def test_no_secrets_no_change(): - content = 'FROM alpine:3.19\nRUN apk add --no-cache curl\nUSER nobody\n' + content = "FROM alpine:3.19\nRUN apk add --no-cache curl\nUSER nobody\n" redacted, count = redact_content(content) assert count == 0 assert redacted == content