Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
823e0c3
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 5, 2026
296ba90
test(security): cover DSN plus and boundary semantics
seonghobae Aug 5, 2026
c81beac
fix(security): preserve URL userinfo semantics
seonghobae Aug 5, 2026
4b50e12
chore: remove superseded agent journal entry
seonghobae Aug 5, 2026
f2343ac
docs(security): define DSN redaction semantics
seonghobae Aug 5, 2026
52ed2b1
docs(changelog): record DSN redaction hardening
seonghobae Aug 5, 2026
7832d5b
docs(changelog): preserve existing release notes
seonghobae Aug 5, 2026
b13dfdc
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 5, 2026
5c2eb66
test(security): preserve DSN decoding domains
seonghobae Aug 5, 2026
ed2a216
fix(security): separate DSN decoding domains
seonghobae Aug 5, 2026
74a8b57
docs: remove transient DSN agent journal entry
seonghobae Aug 5, 2026
05c683d
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 5, 2026
bdb70d9
test(security): expose DSN decoding and boundary regressions
seonghobae Aug 6, 2026
3627ccb
fix(security): preserve URL userinfo semantics and Unicode boundaries
seonghobae Aug 6, 2026
e58872a
chore: restore canonical Sentinel journal
seonghobae Aug 6, 2026
b8ae7c0
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 6, 2026
6425351
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 6, 2026
522c9e7
ci(pr745): rebuild focused DSN redaction fix
seonghobae Aug 6, 2026
7c46d80
fix(security): restore focused DSN redaction boundary
github-actions[bot] Aug 6, 2026
b26638a
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 6, 2026
b11c977
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 6, 2026
523d605
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 6, 2026
d8091ed
๐Ÿ›ก๏ธ Sentinel: [CRITICAL] Fix incomplete DSN secret redaction and over-โ€ฆ
seonghobae Aug 6, 2026
f9443e5
test(security): cover standalone and non-corrupting DSN redaction
seonghobae Aug 6, 2026
11acfb2
fix(security): separate URL and form secret encodings
seonghobae Aug 6, 2026
7930009
fix(deps): restore complete typed development contract
seonghobae Aug 6, 2026
b5ac3c8
fix(ci): restore immutable dependency lockfiles
seonghobae Aug 6, 2026
5467f8c
docs(security): define DSN redaction evidence and contract
seonghobae Aug 6, 2026
0d5ea7f
docs(changelog): record DSN secret redaction hardening
seonghobae Aug 6, 2026
07f19f9
chore: merge protected main into DSN redaction branch
seonghobae Aug 13, 2026
e8d5e0b
Merge branch 'main' into sentinel/dsn-redaction-fix-14431549202957900548
opencode-agent[bot] Aug 14, 2026
1e510c3
Merge main into sentinel/dsn-redaction-fix-14431549202957900548
seonghobae Aug 19, 2026
ed42bac
fix(security): preserve unrelated assignment keys
seonghobae Aug 20, 2026
5924869
Merge branch 'main' into sentinel/dsn-redaction-fix-14431549202957900548
seonghobae Aug 20, 2026
5887448
Merge branch 'main' into sentinel/dsn-redaction-fix-14431549202957900548
seonghobae Aug 20, 2026
7c8afef
Merge branch 'main' into sentinel/dsn-redaction-fix-14431549202957900548
seonghobae Aug 20, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`๋ฅผ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. ๋‹จ์œ„ ํ…Œ์ŠคํŠธ๋กœ ๋ˆ„์ถœ์„ ์ฐจ๋‹จํ•ฉ๋‹ˆ๋‹ค.
Expand Down
73 changes: 51 additions & 22 deletions backend/app/dsn_redaction.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"""Redact DSN-derived credentials from database driver error messages."""

from __future__ import annotations

import re
Expand All @@ -9,22 +11,15 @@
re.IGNORECASE,
)
_SECRET_ASSIGNMENT_PATTERN = re.compile(
r"(?P<prefix>\b[\w.-]*(?:pass(?:word|wd)?|pwd|token|secret|private[_-]?key|"
r"api[_-]?key|access[_-]?key|auth(?:entication)?)[\w.-]*\s*[:=]\s*)"
r"(?P<prefix>\b[\w.-]*?(?<![A-Za-z0-9])(?:pass(?:word|wd)?|pwd|token|secret|private[_-]?key|"
r"api[_-]?key|access[_-]?key|auth(?:entication)?)[\w.-]*?\s*[:=]\s*)"
r"(?P<value>[^&\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("://")
Expand All @@ -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:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
# 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
Expand All @@ -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"(?<![A-Za-z0-9]){re.escape(secret)}(?![A-Za-z0-9])")
pattern = re.compile(
rf"(?<!\w){re.escape(secret)}(?!\w)",
flags=re.UNICODE | re.IGNORECASE,
)
return pattern.sub("***", message)


def redact_dsn_error_message(error_message: str, dsn: str) -> 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<prefix>***", 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<prefix>***", redacted)
return redacted
Comment thread
coderabbitai[bot] marked this conversation as resolved.
106 changes: 106 additions & 0 deletions backend/tests/test_dsn_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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")
107 changes: 107 additions & 0 deletions docs/doctoring/dsn-secret-redaction.md
Original file line number Diff line number Diff line change
@@ -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
`(?<!\w)` and `(?!\w)` boundaries, which mask a standalone value without
corrupting a larger identifier or natural-language word that merely contains
it. Malformed authorities use best-effort extraction and never cause the
redaction boundary itself to raise. The complete diagnostic message is
preserved; redaction does not impose an unrelated truncation policy.

## Verification

`backend/tests/test_dsn_redaction.py` covers:

- raw, decoded, and canonical encoded user-information passwords;
- query tokens with form-query decoding semantics;
- a literal-plus password without treating an unrelated space as equivalent;
- a `%20` user-information password without treating unrelated `a+b` text as
equivalent;
- standalone Unicode and punctuation-bearing short secrets while preserving
larger surrounding words;
- malformed and scheme-less DSNs;
- case-insensitive standalone candidates;
- assignment sanitization combined with DSN-derived candidates; and
- preservation of messages longer than 1,000 characters.

CI must install the immutable, hash-locked development dependency set and run
static typing and the full backend tests on the exact pull-request head. Security
Scan and Semgrep remain independent required gates.

## Operational impact

The function returns a sanitized diagnostic string. It does not alter connection
establishment, driver selection, DSN storage, authentication, or database
behavior. Downstream code should still avoid logging raw DSNs and should keep
structured secret fields out of log records. Redaction is a defense-in-depth
boundary, not permission to collect plaintext credentials.

## Research basis

Krause et al. (2023) found that accidental secret leakage is common in source
code workflows and that prevention and remediation mechanisms need low adoption
cost. Applying automatic redaction at the common error-message boundary reduces
the number of callers that must implement secret-specific handling correctly.

OWASP identifies passwords, access tokens, database connection strings,
credentials, and similar values as data that should not be recorded in plaintext
logs. Its secrets-management guidance explicitly requires encryption or masking
when a secret could otherwise reach a log. Python's `urllib.parse`
documentation is authoritative for the semantic distinction that
`unquote_plus` maps plus signs to spaces for form values, while `unquote` does
not.

The peer-reviewed paper is linked to the publisher-maintained open-access copy
rather than vendored as a binary, preserving provenance and avoiding a stale or
license-ambiguous repository copy.

## References

Krause, A., Klemmer, J. H., Huaman, N., Wermke, D., Acar, Y., & Fahl, S.
(2023). Pushed by accident: A mixed-methods study on strategies of handling
secret information in source code repositories. In *32nd USENIX Security
Symposium (USENIX Security 23)* (pp. 2527โ€“2544). USENIX Association.
https://www.usenix.org/conference/usenixsecurity23/presentation/krause

Open Worldwide Application Security Project. (n.d.). *Logging cheat sheet*.
OWASP Cheat Sheet Series. Retrieved August 6, 2026, from
https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html

Open Worldwide Application Security Project. (n.d.). *Secrets management cheat
sheet*. OWASP Cheat Sheet Series. Retrieved August 6, 2026, from
https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html

Python Software Foundation. (n.d.). *urllib.parseโ€”Parse URLs into components*.
Python documentation. Retrieved August 6, 2026, from
https://docs.python.org/3/library/urllib.parse.html
Loading