Skip to content
2 changes: 1 addition & 1 deletion src/kapacitor/Commands/WatchCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ CancellationToken ct
}

if (!string.IsNullOrWhiteSpace(line)) {
newLines.Add(line);
newLines.Add(SecretRedactor.RedactLine(line));
newLineNumbers.Add(lineIndex);
}

Expand Down
106 changes: 106 additions & 0 deletions src/kapacitor/SecretRedactor.cs
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();
Comment on lines +53 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Regex can corrupt json 🐞 Bug ≡ Correctness

SecretRedactor runs EnvVar/YAML/connection-string regexes against the full serialized JSONL line;
the value patterns can consume the closing quote/comma of the JSON string field and truncate/corrupt
the JSON line. This can break downstream parsing/ingestion and potentially drop transcript content
when secrets are present in tool_result output.
Agent Prompt
### Issue description
`SecretRedactor.RedactSecrets()` applies regexes to the entire serialized JSONL line. Several patterns (`EnvVarSecretRegex`, `YamlStyleSecretRegex`, and potentially `ConnectionStringPwdRegex`) match "value until whitespace" / "value until ';'" and can therefore consume JSON delimiters (`"`, `,`, `}`) because those are non-whitespace characters in the serialized JSON. This can truncate/corrupt the JSONL line.

### Issue Context
Because redaction is done on the raw JSON line (not parsed string values), every redaction regex must be bounded so it cannot cross a JSON string boundary.

### Fix Focus Areas
- src/kapacitor/SecretRedactor.cs[53-61]
- src/kapacitor/SecretRedactor.cs[88-103]

### Suggested fix
- Update value-capturing groups to stop at JSON-string terminators/escapes, e.g.:
  - Env var value: `([^"\\\s]+)` instead of `\S+`
  - YAML value: `([^"\\\s]{8,})` instead of `\S{8,}`
  - Connection string value: consider excluding `"`/`\\` as well if you keep operating on raw JSON.
- Add unit tests that assert:
  - `JsonDocument.Parse(redactedLine)` succeeds for env-var / YAML / connection-string cases.
  - trailing JSON fields like `"is_error":false` are still present after redaction.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

static readonly Regex ConnectionStringPwdRegex = ConnectionStringPwdRx();
}
232 changes: 232 additions & 0 deletions test/kapacitor.Tests.Unit/SecretRedactorTests.cs
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();
}
}
Loading