From b528065f958cfb94a10066d48b6f09804674beae Mon Sep 17 00:00:00 2001 From: varshith84 <112600740+varshith84@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:45:07 +0530 Subject: [PATCH] fix: redact credentials in database connection URLs --- README.md | 2 +- README.zh.md | 2 +- lib/index.js | 7 ++++++- src/mask.ts | 9 ++++++++- test/mask.test.ts | 35 +++++++++++++++++++++++++++++++++++ 5 files changed, 51 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5d68e64..0e82f06 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ All three read through the same trusted `ctx.sessionQuery` seam. | `/transcript --id ` | Export another session | | `/transcript --last 30m` | **Partial export**: entries from the last 30 minutes (`7d`/`12h`/`30m`/`90s`) | | `/transcript --errors-only` | **Debug view**: failed tool results with a two-entry context window | -| `/transcript --mask` | **Redact likely secrets** (API keys, bearer tokens, private keys, emails) from the output | +| `/transcript --mask` | **Redact likely secrets** (API keys, bearer tokens, private keys, emails, database URL credentials) from the output | | `/transcript --mask-hash` | **Deterministic redaction**: secrets become `#xxxxxxxx` digests — same secret → same marker, equality survives redaction | | `/transcript --manifest` | **Evidence manifest**: write a `.manifest.json` sidecar with byte size + SHA-256 for every artifact of this run | | `/transcript --full` | Append log-only events + Mermaid turn timeline | diff --git a/README.zh.md b/README.zh.md index 82640da..7c3a5bc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -58,7 +58,7 @@ Exported 23 messages (41 tool calls, 128450 tokens) → /path/to/cwd/dsh-transcr | `/transcript --id ` | 导出另一个会话 | | `/transcript --last 30m` | **部分导出**:最近 30 分钟的条目(`7d`/`12h`/`30m`/`90s`) | | `/transcript --errors-only` | **调试视图**:报错的工具结果 ± 两条上下文 | -| `/transcript --mask` | **脱敏**:遮蔽 API key、Bearer token、私钥、邮箱等 | +| `/transcript --mask` | **脱敏**:遮蔽 API key、Bearer token、私钥、邮箱、数据库 URL 凭据等 | | `/transcript --mask-hash` | **确定性脱敏**:密文变 `#xxxxxxxx` 摘要——同密钥同标记,相等性在脱敏后仍可判 | | `/transcript --manifest` | **证据清单**:为本次每个导出物写 `.manifest.json` 边车(字节数 + SHA-256) | | `/transcript --full` | 附上 log-only 事件附录 + Mermaid 轮次时间轴 | diff --git a/lib/index.js b/lib/index.js index 4058b8e..fb658df 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1321,6 +1321,11 @@ ${turns} * that can overlap (Bearer header text also matching a prefixed key). */ const BUILTIN_RULES = [ + { + name: "connection-string", + pattern: /\b((?:postgres(?:ql)?|rediss?|mysql):\/\/)[^:\s/@?#"'<>\\]*:[^\s/@?#"'<>\\]+(?=@)/gi, + replacement: "$1[REDACTED]" + }, { name: "private-key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, @@ -1338,7 +1343,7 @@ const BUILTIN_RULES = [ }, { name: "email", - pattern: /\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b/g, + pattern: /\b(?\\]*:[^\s/@?#"'<>\\]+(?=@)/gi, + replacement: '$1[REDACTED]', + }, { name: 'private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, @@ -48,7 +53,9 @@ const BUILTIN_RULES: readonly MaskRule[] = [ }, { name: 'email', - pattern: /\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b/g, + // A passwordless database URL's user/host pair is not an email. The + // first lookbehind also prevents matching a suffix of its username. + pattern: /\b(? { expect(out).toMatch(/#[0-9a-f]{8}/) }) }) + + +describe('database connection credentials', () => { + const urls = [ + 'postgres://user:secretpass@host:5432/db', + 'postgresql://user:secretpass@host/db', + 'redis://:secretpass@host:6379/0', + 'rediss://user:secretpass@host/0', + 'mysql://user:secretpass@host/db', + 'POSTGRES://user:p%40ss%3Aword@host/db', + 'mysql://user:pass:word@[::1]:3306/db', + ] + for (const mode of ['mask', 'hash'] as const) { + it.each(urls)(`redacts credentials in ${mode} mode: %s`, (url) => { + const prefix = url.slice(0, url.indexOf('://') + 3) + const suffix = url.slice(url.indexOf('@')) + const out = maskText(url, { mode }) + expect(out.startsWith(prefix)).toBe(true) + expect(out.endsWith(suffix)).toBe(true) + expect(out).not.toContain(url.slice(prefix.length, url.indexOf('@'))) + expect(out).toEqual(mode === 'mask' ? `${prefix}[REDACTED]${suffix}` : expect.stringMatching(/:\/\/#[0-9a-f]{8}@/)) + }) + it('leaves URLs without passwords unchanged', () => { + for (const url of ['postgres://host/db', 'redis://host:6379/0', 'mysql://user@host/db', 'postgres://user:@host/db', 'postgres://user@db.example.com/db']) { + expect(maskText(url, { mode })).toBe(url) + } + }) + } + it('redacts separate occurrences while retaining punctuation and non-database URLs', () => { + expect(maskText('("postgres://a:pass@host/db") redis://:pass@cache/0')).toBe( + '("postgres://[REDACTED]@host/db") redis://[REDACTED]@cache/0', + ) + expect(maskText('https://user:pass@host/path')).toBe('https://user:pass@host/path') + }) +})