diff --git a/CHANGELOG.md b/CHANGELOG.md index 35613431a..e2c5bf48e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased +- [BE] ๐Ÿ”’ **DSN ์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€ ๋น„๋ฐ€๊ฐ’ redaction ๊ฐ•ํ™”**: URL user-information๊ณผ form-query ๋””์ฝ”๋”ฉ ๊ทœ์น™์„ ๋ถ„๋ฆฌํ•˜๊ณ , rawยทdecodedยทcanonical encoding ํ›„๋ณด๋ฅผ ๋งˆ์Šคํ‚นํ•ฉ๋‹ˆ๋‹ค. ์งง์€ Unicode/๊ตฌ๋‘์  ๋น„๋ฐ€๊ฐ’์€ ๋” ํฐ ๋‹จ์–ด๋ฅผ ํ›ผ์†ํ•˜์ง€ ์•Š์œผ๋ฉฐ, malformedยทscheme-less DSN์—์„œ๋„ fail-closed best-effort redaction์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. - [BE] ๐Ÿ”’ **Cryptography 50+ ๋ณด์•ˆ ๊ฒฝ๊ณ„ ๊ฐฑ์‹ **: `pyproject.toml`๊ณผ ๋‘ hash-locked ์š”๊ตฌ์‚ฌํ•ญ ํŒŒ์ผ์„ ๋™์ผํ•œ Cryptography 50+ ํ•ด์„์œผ๋กœ ์ •ํ•ฉํ™”ํ•˜์—ฌ PKCS#7 ์˜ค๋ฅ˜ยทํƒ€์ด๋ฐ ๊ตฌ๋ถ„์œผ๋กœ ์ธํ•œ CVE-2026-69247 ์™„ํ™”๋ฅผ ์‹ค์ œ ์„ค์น˜ยท๊ฒ€์ฆ ๊ฒฝ๋กœ์— ๋ฐ˜์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. - [FE] โšก **๊ฒ€์ƒ‰ ๋…ธ๋“œ ์ฐธ์กฐ ์•ˆ์ •ํ™” ๋ฐ ์ˆœ์ฐจ ์Šค๋ƒ…์ƒท ํด๋ง**: ๊ฐ™์€ ์ •๊ทœํ™” ๊ฒ€์ƒ‰์–ด์™€ ์›๋ณธ ํ…Œ์ด๋ธ” ๋ฐ์ดํ„ฐ์—๋Š” ์žฅ์‹๋œ `node.data` ์ฐธ์กฐ๋ฅผ ์žฌ์‚ฌ์šฉํ•˜์—ฌ ๋“œ๋ž˜๊ทธ ์ค‘ ๋ถˆํ•„์š”ํ•œ ํ•˜์œ„ ๋ Œ๋”๋ง๊ณผ ํ• ๋‹น์„ ์ค„์ž…๋‹ˆ๋‹ค. ์Šค๋ƒ…์ƒท ํด๋ง์€ ์ด์ „ ์š”์ฒญ์ด ๋๋‚œ ๋’ค์—๋งŒ ๋‹ค์Œ ์š”์ฒญ์„ ์˜ˆ์•ฝํ•˜๋ฉฐ, ์„ ํƒ ๋ณ€๊ฒฝยท์–ธ๋งˆ์šดํŠธ ํ›„ ๋„์ฐฉํ•œ ์˜ค๋ž˜๋œ ์„ฑ๊ณต ๋˜๋Š” ์‹คํŒจ ์‘๋‹ต์„ ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค. - [BE] ๐Ÿ”’ **๊ณต์œ  export ์ „ ๊ฒฝ๋กœ redaction**: ๊ณต๊ฐœ share์˜ SQL / index-design / reversing-spec export์—์„œ ์ฝ”๋ฉ˜ํŠธยท`example_value`๋ฅผ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋กœ ๋ˆ„์ถœ์„ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค. diff --git a/backend/app/dsn_redaction.py b/backend/app/dsn_redaction.py index 59247833b..acc38cdd4 100644 --- a/backend/app/dsn_redaction.py +++ b/backend/app/dsn_redaction.py @@ -1,3 +1,5 @@ +"""Redact DSN-derived credentials from database driver error messages.""" + from __future__ import annotations import re @@ -9,22 +11,15 @@ re.IGNORECASE, ) _SECRET_ASSIGNMENT_PATTERN = re.compile( - r"(?P\b[\w.-]*(?:pass(?:word|wd)?|pwd|token|secret|private[_-]?key|" - r"api[_-]?key|access[_-]?key|auth(?:entication)?)[\w.-]*\s*[:=]\s*)" + r"(?P\b[\w.-]*?(?[^&\s,;\"'<>]+)", re.IGNORECASE, ) def _split_dsn_best_effort(dsn: str) -> tuple[str, str]: - """Extract (netloc, query) from a DSN without ``urlsplit``. - - ``urllib.parse.urlsplit`` raises ``ValueError`` (e.g. "Invalid IPv6 URL") - on malformed authorities such as an unbalanced ``[``. Redaction must never - crash on hostile input, otherwise the raw, un-redacted error message could - still reach a client. This fallback recovers the credential-bearing parts - with plain string slicing so embedded secrets are still stripped. - """ + """Extract the authority and query from a malformed DSN without raising.""" remainder = dsn scheme_sep = remainder.find("://") @@ -40,36 +35,50 @@ def _split_dsn_best_effort(dsn: str) -> tuple[str, str]: def _password_candidates_from_dsn(dsn: str) -> set[str]: + """Return raw, decoded, and canonical secret representations from a DSN.""" + candidates: set[str] = set() password: str | None = None try: parsed = urlsplit(dsn) if "://" in dsn and not parsed.netloc: - # ponytail: keep urlsplit; only swap the non-RFC scheme so userinfo parses. parsed = urlsplit("http://" + dsn.split("://", 1)[1]) netloc = parsed.netloc password = parsed.password query = parsed.query except ValueError: - # Malformed DSN (e.g. invalid IPv6 literal). Fall back to best-effort - # parsing so any embedded credentials are still redacted. netloc, query = _split_dsn_best_effort(dsn) + if not password and not netloc and "@" in dsn: + try: + parsed_implicit = urlsplit("//" + dsn) + if parsed_implicit.netloc: + netloc = parsed_implicit.netloc + password = parsed_implicit.password + query = parsed_implicit.query + except ValueError: + # Preserve fail-closed best-effort extraction for malformed authorities. + pass + if password: candidates.add(password) - candidates.add(quote(password, safe="")) + decoded = unquote(password) + candidates.add(decoded) + candidates.add(quote(decoded, safe="")) if "@" in netloc: userinfo = netloc.rsplit("@", 1)[0] if ":" in userinfo: raw_password = userinfo.split(":", 1)[1] candidates.add(raw_password) - candidates.add(unquote(raw_password)) + decoded_raw = unquote(raw_password) + candidates.add(decoded_raw) + candidates.add(quote(decoded_raw, safe="")) for part in query.split("&"): - key, sep, raw_value = part.partition("=") - if not sep: + key, separator, raw_value = part.partition("=") + if not separator: continue if not _SECRET_KEY_PATTERN.search(unquote_plus(key)): continue @@ -83,17 +92,37 @@ def _password_candidates_from_dsn(dsn: str) -> set[str]: def _redact_secret_occurrences(message: str, secret: str) -> str: + """Replace exact secret occurrences while protecting larger Unicode words.""" + + if not secret: + return message + if len(secret) > 4: - return message.replace(secret, "***") + pattern = re.compile(re.escape(secret), re.IGNORECASE) + return pattern.sub("***", message) - pattern = re.compile(rf"(? str: - """Redact DSN-derived secrets from a driver error message.""" + """Redact DSN-derived secrets from a database driver error message. + + Args: + error_message: Driver or connection text that may contain credential + assignments or representations derived from the DSN. + dsn: The database connection string used to derive raw, decoded, and + canonical encoded secret candidates. + + Returns: + The complete error message with detected secrets replaced by ``***``. + Unrelated text and message length are otherwise preserved. + """ - redacted = error_message + redacted = _SECRET_ASSIGNMENT_PATTERN.sub(r"\g***", error_message) for secret in sorted(_password_candidates_from_dsn(dsn), key=len, reverse=True): redacted = _redact_secret_occurrences(redacted, secret) - return _SECRET_ASSIGNMENT_PATTERN.sub(r"\g***", redacted) + return redacted diff --git a/backend/tests/test_dsn_redaction.py b/backend/tests/test_dsn_redaction.py index 61ae2ffa1..1d2d954c2 100644 --- a/backend/tests/test_dsn_redaction.py +++ b/backend/tests/test_dsn_redaction.py @@ -34,6 +34,13 @@ def test_short_dsn_password_does_not_corrupt_secret_key_names() -> None: assert "***word" not in redacted +def test_unrelated_assignment_key_is_preserved() -> None: + dsn = "postgresql://user@db.example.com/app" + error = "driver reported bypass=enabled" + + assert redact_dsn_error_message(error, dsn) == error + + def test_malformed_dsn_still_redacts_embedded_secrets() -> None: dsn = "postgresql://user:s3cr3t@[bad/db?password=q%2Fsecret" error = f"driver failed for s3cr3t with password=q/secret while using {dsn}" @@ -43,3 +50,102 @@ def test_malformed_dsn_still_redacts_embedded_secrets() -> None: assert "s3cr3t" not in redacted assert "q/secret" not in redacted assert "password=***" in redacted + + +def test_url_encoded_short_passwords_and_boundaries() -> None: + dsn = "postgresql://user:a%2Bb@db.example.com/app" + error = "driver failed for a+b (a%2Bb) with =a+ and b+=" + + redacted = redact_dsn_error_message(error, dsn) + + assert "a+b" not in redacted + assert "a%2Bb" not in redacted + + +def test_userinfo_literal_plus_is_not_decoded_as_space() -> None: + dsn = "postgresql://user:a+b@db.example.com/app" + error = "driver exposed a+b, but the unrelated phrase a b must remain" + + redacted = redact_dsn_error_message(error, dsn) + + assert "a+b" not in redacted + assert "unrelated phrase a b must remain" in redacted + + +def test_userinfo_space_encoding_does_not_redact_literal_plus() -> None: + dsn = "postgresql://user:a%20b@db.example.com/app" + error = "driver exposed a b and a%20b; unrelated literal a+b must remain" + + redacted = redact_dsn_error_message(error, dsn) + + assert redacted == "driver exposed *** and ***; unrelated literal a+b must remain" + + +def test_short_unicode_secret_uses_unicode_word_boundaries() -> None: + dsn = "postgresql://user@db.example.com/app?token=ํ‚ค" + error = "standalone ํ‚ค must be hidden while ๋น„๋ฐ€ํ‚ค๊ฐ’ remains readable" + + redacted = redact_dsn_error_message(error, dsn) + + assert redacted == "standalone *** must be hidden while ๋น„๋ฐ€ํ‚ค๊ฐ’ remains readable" + + +def test_short_punctuation_secret_is_not_redacted_inside_larger_text() -> None: + dsn = "postgresql://user:+a+@db.example.com/app" + error = "isolated +a+ must be hidden while x+a+y remains readable" + + redacted = redact_dsn_error_message(error, dsn) + + assert "isolated *** must be hidden" in redacted + assert "x+a+y remains readable" in redacted + + +def test_punctuation_secret_is_redacted_when_adjacent_to_equals() -> None: + dsn = "postgresql://user:+a+@db.example.com/app" + error = "driver failed with password=+a+" + + redacted = redact_dsn_error_message(error, dsn) + + assert "password=***" in redacted + + +def test_redact_secret_occurrences_case_insensitive() -> None: + dsn = "postgresql://user:SECRET@db.example.com/app?token=KeY" + error = "driver failed with secret and key" + + redacted = redact_dsn_error_message(error, dsn) + + assert redacted == "driver failed with *** and ***" + + +def test_mixed_dsn_and_assignment_pattern() -> None: + dsn = "postgresql://user:secret123@localhost/db" + error1 = "DSN: user:secret123@localhost and password = secret123" + error2 = "password = secret123 and DSN: user:secret123@localhost" + + assert redact_dsn_error_message(error1, dsn) == ( + "DSN: user:***@localhost and password = ***" + ) + assert redact_dsn_error_message(error2, dsn) == ( + "password = *** and DSN: user:***@localhost" + ) + + +def test_schemeless_dsn_password_redaction() -> None: + dsn = "user:pass123@localhost/db" + error = "Connection failed for user:pass123@localhost" + + result = redact_dsn_error_message(error, dsn) + + assert result == "Connection failed for user:***@localhost" + + +def test_long_error_message_is_redacted_without_truncation() -> None: + dsn = "postgresql://user:secret123@localhost/db" + error = f"prefix {'x' * 1200} secret123 suffix" + + redacted = redact_dsn_error_message(error, dsn) + + assert redacted.startswith("prefix ") + assert "x" * 1200 in redacted + assert redacted.endswith(" *** suffix") diff --git a/docs/doctoring/dsn-secret-redaction.md b/docs/doctoring/dsn-secret-redaction.md new file mode 100644 index 000000000..728d53e28 --- /dev/null +++ b/docs/doctoring/dsn-secret-redaction.md @@ -0,0 +1,107 @@ +# DSN Secret Redaction Boundary + +## Incident class + +Database drivers and connection libraries can repeat a database source name +(DSN), its user-information, or secret-bearing query parameters in an exception. +Passing those messages through unchanged can disclose passwords, tokens, API +keys, and connection strings in API responses, support bundles, or logs. + +The risk is not limited to one textual representation. A driver can emit the raw +percent-encoded value, a decoded value, or a canonical re-encoding. A redactor +that handles only one form can leave equivalent credentials visible. Conversely, +using form-query decoding rules for URL user-information can create false +candidates and corrupt unrelated diagnostic text. + +## Decision + +`backend/app/dsn_redaction.py` applies two ordered defenses: + +1. Recognized secret-key assignments such as `password=...`, `token: ...`, and + `api_key=...` are masked independently of DSN parsing. +2. Additional candidates are derived from the supplied DSN and replaced in the + remaining message. + +The candidate domains remain intentionally separate: + +- URL user-information passwords use `urllib.parse.unquote`. A literal `+` + remains a plus, while percent-encoded octets such as `%20` are decoded. The + implementation adds the raw, decoded, and `quote(..., safe="")` forms. +- Query-string secret values use `urllib.parse.unquote_plus`, because HTML form + query semantics map `+` to a space. The implementation adds raw, decoded, + `quote`, and `quote_plus` forms only in this domain. + +Candidates longer than four characters are replaced by exact, +case-insensitive occurrence. Short candidates use Unicode-aware +`(?