diff --git a/tests/test_app_js.py b/tests/test_app_js.py index 90964f02..74df85ad 100644 --- a/tests/test_app_js.py +++ b/tests/test_app_js.py @@ -1350,6 +1350,10 @@ def test_redact_credentials_runtime_smoke() -> None: + "const apg = redactCredentials('postgresql+asyncpg://user:s3cret@db/app');\n" + "if (apg !== 'postgresql+asyncpg://user:[REDACTED:password]@db/app') " + "throw new Error('asyncpg conn redact failed: ' + apg);\n" + + "// RFC 3986 schemes are case-insensitive - uppercase must not bypass\n" + + "const up = redactCredentials('POSTGRESQL+PSYCOPG2://user:s3cret@db/app');\n" + + "if (up !== 'POSTGRESQL+PSYCOPG2://user:[REDACTED:password]@db/app') " + + "throw new Error('uppercase scheme conn redact failed: ' + up);\n" ) with tempfile.NamedTemporaryFile(mode="w", suffix=".mjs", delete=False) as f: f.write(harness) diff --git a/tests/test_output_guard.py b/tests/test_output_guard.py index e7cb7935..7c6e5264 100644 --- a/tests/test_output_guard.py +++ b/tests/test_output_guard.py @@ -250,6 +250,18 @@ class TestCredentialLeakage: assert "s3cret_pass" not in r.sanitized, url assert ":[REDACTED:password]@" in r.sanitized, url + def test_uppercase_scheme_connection_string(self) -> None: + # RFC 3986 schemes are case-insensitive; an uppercase scheme must + # not bypass redaction. + for url in ( + "POSTGRESQL+PSYCOPG2://admin:s3cret_pass@db.internal/prod", + "HTTPS://admin:s3cret_pass@api.internal/x", + ): + r = evaluate_output(url) + assert "connection_string_leak" in r.flags, url + assert r.sanitized is not None, url + assert "s3cret_pass" not in r.sanitized, url + def test_bearer_scheme_case_insensitive(self) -> None: # RFC 7235 scheme name is case-insensitive. r = evaluate_output("authorization: bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig12345") diff --git a/turnstone/core/output_guard.py b/turnstone/core/output_guard.py index 4862755f..4528b9e2 100644 --- a/turnstone/core/output_guard.py +++ b/turnstone/core/output_guard.py @@ -98,9 +98,12 @@ _RE_PRIVATE_KEY_BLOCK = re.compile( # covers SQLAlchemy dialect+driver URLs (``postgresql+psycopg2``, # ``postgresql+asyncpg``, ``mysql+pymysql``) and ``mongodb+srv`` — # enumerating drivers is a losing game, the suffix shape isn't. +# Schemes are case-insensitive per RFC 3986, hence IGNORECASE: +# ``POSTGRESQL://`` leaks the same password ``postgresql://`` does. _RE_CONNECTION_STRING = re.compile( r"(?:postgresql|mysql|mongodb|rediss?|amqps?|sqlite|https?)(?:\+[a-z0-9]*)?" r"://[^:@\s]+:[^@\s]+@", + re.IGNORECASE, ) _RE_ENV_SECRET_LINE = re.compile(r"[A-Z][A-Z_0-9]+=\S+") _RE_ENV_SECRET_KEY = re.compile( diff --git a/turnstone/shared_static/redact_credentials.js b/turnstone/shared_static/redact_credentials.js index b4d80c5d..dffd01c8 100644 --- a/turnstone/shared_static/redact_credentials.js +++ b/turnstone/shared_static/redact_credentials.js @@ -37,10 +37,11 @@ const _RE_PRIVATE_KEY_BLOCK = // The optional +suffix covers SQLAlchemy dialect+driver URLs // (postgresql+psycopg2, postgresql+asyncpg, mysql+pymysql) and // mongodb+srv — enumerating drivers is a losing game, the suffix -// shape isn't. +// shape isn't. Schemes are case-insensitive per RFC 3986 (/i): +// POSTGRESQL:// leaks the same password postgresql:// does. // --------------------------------------------------------------------------- const _RE_CONNECTION_STRING = - /(?:postgresql|mysql|mongodb|rediss?|amqps?|sqlite|https?)(?:\+[a-z0-9]*)?:\/\/[^:@\s]+:[^@\s]+@/g; + /(?:postgresql|mysql|mongodb|rediss?|amqps?|sqlite|https?)(?:\+[a-z0-9]*)?:\/\/[^:@\s]+:[^@\s]+@/gi; const _RE_CONN_USERINFO = /:\/\/([^:@\s]+):([^@\s]+)@/;