Repository navigation
Redact secrets from transcript tool results before sending to server #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
9bf0faf
feat: add SecretRedactor with PEM key detection
alexeyzimarev 1b46bde
fix: handle both direct and progress-wrapped transcript formats
alexeyzimarev 0056c9c
feat: add vendor token prefix and AWS key detection
alexeyzimarev 2dd2d1a
feat: add contextual secret detection (JSON keys, env vars, YAML, con…
alexeyzimarev deba40c
test: add toolUseResult redaction and edge case tests
alexeyzimarev 8b65be8
feat: integrate SecretRedactor into watcher transcript pipeline
alexeyzimarev e10c096
fix: use low-entropy fake Stripe tokens to bypass GitHub push protection
alexeyzimarev c50d9b1
fix: prevent regex from consuming JSON delimiters in raw JSONL
alexeyzimarev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| using System.Text.Json; | ||
| using System.Text.RegularExpressions; | ||
|
|
||
| namespace kapacitor; | ||
|
|
||
| static partial class SecretRedactor { | ||
| public static string RedactLine(string rawJsonlLine) { | ||
| try { | ||
| using var doc = JsonDocument.Parse(rawJsonlLine); | ||
| var root = doc.RootElement; | ||
|
|
||
| // Resolve the user message element from either format: | ||
| // Direct format: { "type": "user", "message": { "role": "user", "content": [...] } } | ||
| // Progress format: { "type": "progress", "data": { "message": { "type": "user", "message": { ... } } } } | ||
| JsonElement? userMessage; | ||
| if (root.Str("type") == "user") { | ||
| // Direct format — the root IS the user event; content lives at root.message.content | ||
| userMessage = root; | ||
| } else { | ||
| // Progress wrapper — content lives at data.message.message.content | ||
| var nested = root.Obj("data")?.Obj("message"); | ||
| if (nested is null || nested.Value.Str("type") != "user") | ||
| return rawJsonlLine; | ||
| userMessage = nested; | ||
| } | ||
|
|
||
| var content = userMessage.Value.Obj("message")?.Arr("content"); | ||
| if (content is null) | ||
| return rawJsonlLine; | ||
|
|
||
| var hasToolResult = false; | ||
| foreach (var block in content.Value.EnumerateArray()) { | ||
| if (block.Str("type") == "tool_result") { | ||
| hasToolResult = true; | ||
| break; | ||
| } | ||
| } | ||
| if (!hasToolResult) | ||
| return rawJsonlLine; | ||
|
|
||
| // We have tool_result blocks — redact the line as a raw string. | ||
| // Working on the serialized JSON string lets us handle both the | ||
| // content field and toolUseResult without manual tree rewriting. | ||
| var redacted = RedactSecrets(rawJsonlLine); | ||
|
|
||
| return redacted == rawJsonlLine ? rawJsonlLine : redacted; | ||
| } catch { | ||
| // Malformed JSON — pass through unchanged | ||
| return rawJsonlLine; | ||
| } | ||
| } | ||
|
|
||
| static string RedactSecrets(string text) { | ||
| text = PemBlockRegex.Replace(text, "[REDACTED]"); | ||
| text = AwsAccessKeyRegex.Replace(text, "[REDACTED]"); | ||
| text = VendorTokenRegex.Replace(text, "[REDACTED]"); | ||
| text = JsonKeySecretRegex.Replace(text, "$1[REDACTED]$3"); | ||
| text = EnvVarSecretRegex.Replace(text, "$1[REDACTED]"); | ||
| text = YamlStyleSecretRegex.Replace(text, "$1[REDACTED]"); | ||
| text = ConnectionStringPwdRegex.Replace(text, "$1[REDACTED]$3"); | ||
| return text; | ||
| } | ||
|
|
||
| // Matches PEM private key blocks (handles both real newlines and \\n escaped newlines in JSON strings) | ||
| [GeneratedRegex(@"-----BEGIN[A-Z\s]*PRIVATE KEY-----(?:\\n|[\s\S])*?-----END[A-Z\s]*PRIVATE KEY-----", RegexOptions.None)] | ||
| private static partial Regex PemBlockRx(); | ||
| static readonly Regex PemBlockRegex = PemBlockRx(); | ||
|
|
||
| // AWS access key IDs: AKIA followed by 16 uppercase alphanumeric chars | ||
| [GeneratedRegex(@"AKIA[0-9A-Z]{16}", RegexOptions.None)] | ||
| private static partial Regex AwsAccessKeyRx(); | ||
| static readonly Regex AwsAccessKeyRegex = AwsAccessKeyRx(); | ||
|
|
||
| // Known vendor token prefixes followed by token characters | ||
| // Each prefix is specific enough to avoid false positives | ||
| [GeneratedRegex(@"(?:ghp_|gho_|ghs_|github_pat_|cfat_|sk-(?:proj-|live_|test_)?|sk_live_|sk_test_|xoxb-|xoxp-|xoxa-|pypi-|npm_|glpat-)[A-Za-z0-9\-_]{10,}", RegexOptions.None)] | ||
| private static partial Regex VendorTokenRx(); | ||
| static readonly Regex VendorTokenRegex = VendorTokenRx(); | ||
|
|
||
| // JSON key: matches "secret_name": "value" or \"secret_name\": \"value\" (escaped quotes inside JSON strings) | ||
| // group 1 = opening quote(s) + key name + closing quote(s) + colon + space + opening quote(s) | ||
| // group 2 = value | ||
| // group 3 = closing quote(s) | ||
| [GeneratedRegex("""((?:\\"|")(?:[^"\\]*(?:secret|token|password|passwd|pwd|api_key|apikey|private_key|credentials|client_secret|access_key|auth_token)[^"\\]*)(?:\\"|")[ \t]*:[ \t]*(?:\\"|"))([^"\\]+)((?:\\"|"))""", RegexOptions.IgnoreCase)] | ||
| private static partial Regex JsonKeySecretRx(); | ||
| static readonly Regex JsonKeySecretRegex = JsonKeySecretRx(); | ||
|
|
||
| // Env var: SECRET_NAME=value (uppercase key containing secret keyword, value until whitespace or JSON delimiter) | ||
| // Excludes " and \ to avoid consuming JSON string boundaries when matching against raw serialized JSON | ||
| [GeneratedRegex(@"([A-Z_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|PWD|API_KEY|APIKEY|PRIVATE_KEY|CREDENTIALS|CLIENT_SECRET|ACCESS_KEY|AUTH_TOKEN)[A-Z_]*=)([^\s""\\]+)", RegexOptions.IgnoreCase)] | ||
| private static partial Regex EnvVarSecretRx(); | ||
| static readonly Regex EnvVarSecretRegex = EnvVarSecretRx(); | ||
|
|
||
| // YAML-style: secret_name: value (key containing secret keyword followed by colon, space, and value) | ||
| // Excludes " and \ to avoid crossing JSON string boundaries. Minimum 8 chars to reduce false positives. | ||
| [GeneratedRegex(@"((?:secret|token|password|passwd|pwd|api_key|apikey|private_key|credentials|client_secret|access_key|auth_token)[^:\n]*:[ \t]+)([^\s""\\]{8,})", RegexOptions.IgnoreCase)] | ||
| private static partial Regex YamlStyleSecretRx(); | ||
| static readonly Regex YamlStyleSecretRegex = YamlStyleSecretRx(); | ||
|
|
||
| // Connection string: Password=value; or Pwd=value; | ||
| // Excludes " and \ to avoid crossing JSON string boundaries | ||
| // group 1 = key=, group 2 = value, group 3 = ; or end | ||
| [GeneratedRegex(@"((?:Password|Pwd)\s*=\s*)([^;""\\]+)(;|$)", RegexOptions.IgnoreCase)] | ||
| private static partial Regex ConnectionStringPwdRx(); | ||
| static readonly Regex ConnectionStringPwdRegex = ConnectionStringPwdRx(); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| namespace kapacitor.Tests.Unit; | ||
|
|
||
| public class SecretRedactorTests { | ||
| [Test] | ||
| public async Task RedactsLine_PemPrivateKey_InToolResult() { | ||
| var line = """ | ||
| {"type":"progress","data":{"message":{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy5AoG5cwSxzXf\n-----END RSA PRIVATE KEY-----","is_error":false}]}}},"uuid":"abc123","timestamp":"2026-01-01T00:00:00Z"} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn"); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| await Assert.That(result).Contains("toolu_1"); // structure preserved | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task RedactsLine_PemPrivateKey_InToolResult_DirectFormat() { | ||
| // Direct format: root type=user, no progress wrapper | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_2","type":"tool_result","content":"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy5AoG5cwSxzXf\n-----END RSA PRIVATE KEY-----","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("MIIEpAIBAAKCAQEA0Z3VS5JJcds3xfn"); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| await Assert.That(result).Contains("toolu_2"); // structure preserved | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task PassesThrough_NonToolResult_Unchanged() { | ||
| var line = """{"type":"progress","data":{"message":{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}}},"uuid":"abc","timestamp":"2026-01-01T00:00:00Z"}"""; | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).IsEqualTo(line); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task PassesThrough_MalformedJson_Unchanged() { | ||
| var line = "not json at all"; | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).IsEqualTo(line); | ||
| } | ||
|
|
||
| [Test] | ||
| [Arguments("ghp_ABCDEFghijklmnop1234567890abcdef12345678", "GitHub PAT")] | ||
| [Arguments("gho_ABCDEFghijklmnop1234567890ab", "GitHub OAuth")] | ||
| [Arguments("ghs_ABCDEFghijklmnop1234567890ab", "GitHub App")] | ||
| [Arguments("github_pat_ABCDEF1234567890abcdef", "GitHub fine-grained PAT")] | ||
| [Arguments("cfat_ABCDEFghijklmnop1234567890ab", "Cloudflare")] | ||
| [Arguments("sk-proj-ABCDEFghijklmnop1234567890ab", "OpenAI")] | ||
| [Arguments("sk_live_00000000000FAKEFAKEFAKE00", "Stripe live")] | ||
| [Arguments("sk_test_00000000000FAKEFAKEFAKE00", "Stripe test")] | ||
| [Arguments("xoxb-1234-5678-abcdef", "Slack bot")] | ||
| [Arguments("xoxp-1234-5678-abcdef", "Slack user")] | ||
| [Arguments("pypi-ABCDEFghijklmnop1234567890ab", "PyPI")] | ||
| [Arguments("npm_ABCDEFghijklmnop1234567890ab", "npm")] | ||
| [Arguments("glpat-ABCDEFghijklmnop12345", "GitLab PAT")] | ||
| public async Task RedactsLine_VendorToken_InToolResult(string token, string _description) { | ||
| var line = $$$""" | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"The token is {{{token}}} here","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain(token); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task RedactsLine_AwsAccessKey_InToolResult() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("AKIAIOSFODNN7EXAMPLE"); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task RedactsLine_JsonKeySecret_InToolResult() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"{ \"client_secret\": \"a8f3b2c91d4e7f0123456789abcdef01\" }","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("a8f3b2c91d4e7f0123456789abcdef01"); | ||
| await Assert.That(result).Contains("client_secret"); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task RedactsLine_EnvVarToken_InToolResult() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"HETZNER_API_TOKEN=abc123def456ghi789jkl","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("abc123def456ghi789jkl"); | ||
| await Assert.That(result).Contains("HETZNER_API_TOKEN"); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task RedactsLine_YamlStyleSecret_InToolResult() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"api_key: sk_reallyLongSecretValue123456","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("sk_reallyLongSecretValue123456"); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task RedactsLine_ConnectionStringPassword_InToolResult() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"Server=localhost;Database=mydb;Password=hunter2;User Id=sa","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("hunter2"); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task DoesNotRedact_CleanToolResult() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"total 40\ndrwxr-xr-x 8 user staff 256 Feb 11 22:54 .\n-rw-r--r-- 1 user staff 611 Feb 10 22:33 Program.cs","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).IsEqualTo(line); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task RedactsLine_SecretInToolUseResult() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"ok","is_error":false}]},"toolUseResult":{"content":"-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEIBkg\n-----END EC PRIVATE KEY-----"}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("MHQCAQEEIBkg"); | ||
| await Assert.That(result).Contains("[REDACTED]"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task PassesThrough_UserMessage_Unchanged() { | ||
| var line = """{"type":"user","message":{"role":"user","content":"my secret password is hunter2"}}"""; | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| // User messages with string content (not array with tool_result) are not scanned | ||
| await Assert.That(result).IsEqualTo(line); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task PassesThrough_ToolUse_Unchanged() { | ||
| var line = """{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/etc/secret.pem"}}]}}"""; | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).IsEqualTo(line); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task RedactsLine_MultipleSecrets_InSameToolResult() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"GITHUB_TOKEN=ghp_abc123def456ghi789jkl012mno345pqrs678\nAWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE\nDB_PASSWORD=supersecretvalue123","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| await Assert.That(result).DoesNotContain("ghp_abc123"); | ||
| await Assert.That(result).DoesNotContain("AKIAIOSFODNN7EXAMPLE"); | ||
| await Assert.That(result).DoesNotContain("supersecretvalue123"); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task PassesThrough_EmptyLine_Unchanged() { | ||
| await Assert.That(SecretRedactor.RedactLine("")).IsEqualTo(""); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task PassesThrough_WhitespaceLine_Unchanged() { | ||
| await Assert.That(SecretRedactor.RedactLine(" ")).IsEqualTo(" "); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task PreservesValidJson_AfterEnvVarRedaction() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"HETZNER_API_TOKEN=abc123def456ghi789jkl","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| // Must still be valid JSON after redaction | ||
| using var doc = System.Text.Json.JsonDocument.Parse(result); | ||
| var content = doc.RootElement | ||
| .GetProperty("message") | ||
| .GetProperty("content"); | ||
| // is_error field must survive redaction | ||
| await Assert.That(content[0].GetProperty("is_error").GetBoolean()).IsFalse(); | ||
| } | ||
|
|
||
| [Test] | ||
| public async Task PreservesValidJson_AfterConnectionStringRedaction() { | ||
| var line = """ | ||
| {"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"Server=localhost;Database=mydb;Password=hunter2;User Id=sa","is_error":false}]}} | ||
| """.Trim(); | ||
|
|
||
| var result = SecretRedactor.RedactLine(line); | ||
|
|
||
| using var doc = System.Text.Json.JsonDocument.Parse(result); | ||
| var content = doc.RootElement | ||
| .GetProperty("message") | ||
| .GetProperty("content"); | ||
| await Assert.That(content[0].GetProperty("is_error").GetBoolean()).IsFalse(); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
1. Regex can corrupt json
🐞 Bug≡ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools