Skip to content
Merged
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
41 changes: 33 additions & 8 deletions docksec/redact.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]

Expand All @@ -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<prefix>[a-zA-Z][a-zA-Z0-9+.-]*://[^:@/\s]*:)(?P<pw>[^@/\s]+)(?P<at>@)"
)

# 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(
Expand Down Expand Up @@ -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)
Expand Down
79 changes: 52 additions & 27 deletions tests/test_redact.py
Original file line number Diff line number Diff line change
@@ -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
Loading