Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ All three read through the same trusted `ctx.sessionQuery` seam.
| `/transcript --id <sessionId>` | 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 |
Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Exported 23 messages (41 tool calls, 128450 tokens) → /path/to/cwd/dsh-transcr
| `/transcript --id <sessionId>` | 导出另一个会话 |
| `/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 轮次时间轴 |
Expand Down
7 changes: 6 additions & 1 deletion lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1338,7 +1343,7 @@ const BUILTIN_RULES = [
},
{
name: "email",
pattern: /\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b/g,
pattern: /\b(?<![\w.+-])(?<!(?:postgres(?:ql)?|rediss?|mysql):\/\/)[\w.+-]+@[\w-]+\.[\w.]{2,}\b/gi,
replacement: "[EMAIL]"
}
];
Expand Down
9 changes: 8 additions & 1 deletion src/mask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ interface MaskRule {
* that can overlap (Bearer header text also matching a prefixed key).
*/
const BUILTIN_RULES: readonly MaskRule[] = [
{
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,
Expand All @@ -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(?<![\w.+-])(?<!(?:postgres(?:ql)?|rediss?|mysql):\/\/)[\w.+-]+@[\w-]+\.[\w.]{2,}\b/gi,
replacement: '[EMAIL]',
},
]
Expand Down
35 changes: 35 additions & 0 deletions test/mask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,38 @@ describe('hash mode', () => {
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')
})
})