Skip to content
Merged
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
7 changes: 4 additions & 3 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,11 @@ By default, Reactotron redacts sensitive data from all MCP responses so that tok

Out of the box, the following are replaced with `[REDACTED]`:

- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `Proxy-Authorization`
- **Object keys** — `password`, `secret`, `api_key`, `access_token`, `refresh_token`, `private_key`, `credentials`, `ssn`, `creditcard`, and variants
- **String values** matching common token formats — Bearer tokens, JWTs (`eyJ...`), OpenAI keys (`sk-...`), GitHub PATs (`ghp_...`), Slack tokens (`xoxb-...`)
- **HTTP headers** — `Authorization`, `Cookie`, `Set-Cookie`, `X-Api-Key`, `X-Auth-Token`, `Proxy-Authorization`, `X-CSRF-Token`, `X-XSRF-Token`, `CSRF-Token`, `X-Forwarded-For`, `X-Real-IP`
- **Object keys** — `password`, `passwd`, `pwd`, `secret`, `client_secret`, `api_key`, `token`, `bearer`, `jwt`, `access_token`, `refresh_token`, `id_token`, `session`, `sessionid`, `csrf`, `xsrf`, `private_key`, `credentials`, `ssn`, `creditcard`, and variants
- **String values** matching common token formats — Bearer tokens, JWTs (`eyJ...`), OpenAI keys (`sk-...`), Anthropic keys (`sk-ant-...`), GitHub PATs/OAuth/user-to-server tokens (`ghp_/ghs_/gho_/ghu_/ghr_...`), Slack tokens (`xoxb-...`), AWS access key IDs (`AKIA...`), Google API keys (`AIza...`), Stripe keys (`sk_live_/pk_test_/...`), and PEM-encoded private key blocks
- **URL query parameters** whose names match any sensitive key (e.g. `?api_key=abc` becomes `?api_key=[REDACTED]`)
- **Form-urlencoded bodies** — strings shaped like `k=v&k=v` (e.g. `application/x-www-form-urlencoded` request bodies) get the same per-field redaction as URL query parameters

### Configuring redaction in Reactotron

Expand Down
65 changes: 59 additions & 6 deletions lib/reactotron-mcp/src/redaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,44 @@ export const DEFAULT_REDACTION_RULES: McpRedactionRules = {
headerNames: [
"authorization", "cookie", "set-cookie",
"x-api-key", "x-auth-token", "proxy-authorization",
"x-csrf-token", "x-xsrf-token", "csrf-token",
"x-forwarded-for", "x-real-ip",
],
sensitiveKeys: [
"password", "secret", "apikey", "api_key", "accesstoken",
"access_token", "refreshtoken", "refresh_token", "privatekey",
"private_key", "credentials", "ssn", "creditcard",
"password", "passwd", "pwd",
"secret", "client_secret", "clientsecret",
"apikey", "api_key", "x-api-key",
"accesstoken", "access_token",
"refreshtoken", "refresh_token",
"idtoken", "id_token",
"token", "bearer", "jwt",
"session", "sessionid", "session_id",
"csrf", "xsrf", "csrf_token", "xsrf_token",
"privatekey", "private_key",
"credentials", "ssn", "creditcard",
],
statePathPatterns: [],
valuePatterns: [
// Bearer tokens
"Bearer\\s+[A-Za-z0-9\\-._~+/]+=*",
// JWTs (header.payload[.signature])
"eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}",
"sk-[a-zA-Z0-9]{20,}",
"ghp_[a-zA-Z0-9]{30,}",
// OpenAI-style keys (also matches our legacy "sk-..." pattern)
"sk-[a-zA-Z0-9_-]{20,}",
// Anthropic API keys
"sk-ant-[a-zA-Z0-9_-]{20,}",
// GitHub PATs / fine-grained tokens
"gh[pousr]_[A-Za-z0-9]{30,}",
// Slack tokens
"xox[bpoas]-[a-zA-Z0-9\\-]{10,}",
// AWS access key IDs
"AKIA[0-9A-Z]{16}",
// Google API keys
"AIza[0-9A-Za-z\\-_]{35}",
// Stripe keys (live/test, secret/publishable/restricted)
"(?:sk|pk|rk)_(?:test|live)_[A-Za-z0-9]{24,}",
// PEM-encoded private key blocks (RSA, EC, DSA, OPENSSH, or generic)
"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\\s\\S]+?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----",
],
}

Expand Down Expand Up @@ -222,7 +247,11 @@ function redactStringValue(value: string, rules: McpRedactionRules): string {
// Redact URL query parameters whose names match sensitiveKeys
const sensitiveKeys = rules.sensitiveKeys ?? []
if (sensitiveKeys.length > 0) {
result = redactUrlQueryParams(result, sensitiveKeys)
if (result.includes("?")) {
result = redactUrlQueryParams(result, sensitiveKeys)
} else if (looksLikeFormEncoded(result)) {
result = redactFormEncodedParams(result, sensitiveKeys)
}
}

// Redact value patterns
Expand All @@ -238,6 +267,30 @@ function redactStringValue(value: string, rules: McpRedactionRules): string {
return result
}

/**
* Detect a form-urlencoded body string (e.g. "user=alice&password=x"). Must be the whole
* string — `foo=bar` mid-sentence won't match. We require at least one "=" and that the
* full string is `k=v(&k=v)*` shape to avoid false positives on casual strings.
*/
function looksLikeFormEncoded(value: string): boolean {
if (!value || value.length > 8192) return false
return /^[\w.\-[\]%]+=[^&]*(?:&[\w.\-[\]%]+=[^&]*)*$/.test(value)
}

/** Redact values in a form-urlencoded body where the param name matches sensitiveKeys. */
function redactFormEncodedParams(value: string, sensitiveKeys: string[]): string {
const sensitiveSet = new Set(sensitiveKeys.map((k) => k.toLowerCase()))
return value.split("&").map((param) => {
const eqIndex = param.indexOf("=")
if (eqIndex === -1) return param
const name = param.slice(0, eqIndex)
if (sensitiveSet.has(name.toLowerCase())) {
return `${name}=${REDACTED}`
}
return param
}).join("&")
}

/** Redact query parameter values in URLs where the param name matches sensitiveKeys. */
function redactUrlQueryParams(value: string, sensitiveKeys: string[]): string {
const qIndex = value.indexOf("?")
Expand Down
158 changes: 158 additions & 0 deletions lib/reactotron-mcp/test/redaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,12 +319,52 @@ describe("DEFAULT_REDACTION_RULES", () => {
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-api-key")
})

test("includes CSRF/XSRF header variants", () => {
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-csrf-token")
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-xsrf-token")
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("csrf-token")
})

test("includes IP-forwarding PII headers", () => {
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-forwarded-for")
expect(DEFAULT_REDACTION_RULES.headerNames).toContain("x-real-ip")
})

test("has expected default sensitive keys", () => {
expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("password")
expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("secret")
expect(DEFAULT_REDACTION_RULES.sensitiveKeys).toContain("access_token")
})

test("includes common auth-token key variants", () => {
const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? []
expect(keys).toContain("token")
expect(keys).toContain("bearer")
expect(keys).toContain("jwt")
expect(keys).toContain("id_token")
expect(keys).toContain("idtoken")
})

test("includes session and CSRF key variants", () => {
const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? []
expect(keys).toContain("session")
expect(keys).toContain("sessionid")
expect(keys).toContain("csrf")
expect(keys).toContain("xsrf")
})

test("includes password aliases", () => {
const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? []
expect(keys).toContain("passwd")
expect(keys).toContain("pwd")
})

test("includes OAuth client_secret variants", () => {
const keys = DEFAULT_REDACTION_RULES.sensitiveKeys ?? []
expect(keys).toContain("client_secret")
expect(keys).toContain("clientsecret")
})

test("value patterns match common token formats", () => {
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }

Expand All @@ -348,4 +388,122 @@ describe("DEFAULT_REDACTION_RULES", () => {
const plainResult = redact("hello world", rules)
expect(plainResult).toBe("hello world")
})

test("value patterns match Anthropic API keys", () => {
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
// Built at runtime so GitHub secret scanning doesn't flag the test file.
const fakeKey = ["sk", "ant"].join("-") + "-" + "x".repeat(32)
expect(redact(fakeKey, rules)).toBe(REDACTED)
})

test("value patterns match AWS access key IDs", () => {
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
const fakeKey = "AKI" + "A" + "X".repeat(16)
expect(redact(fakeKey, rules)).toBe(REDACTED)
})

test("value patterns match Google API keys", () => {
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
// Google API keys are 39 chars: prefix + 35 more.
const fakeKey = "AIz" + "a" + "X".repeat(35)
expect(redact(fakeKey, rules)).toBe(REDACTED)
})

test("value patterns match Stripe keys", () => {
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
const body = "X".repeat(28)
expect(redact(["sk", "live", body].join("_"), rules)).toBe(REDACTED)
expect(redact(["pk", "test", body].join("_"), rules)).toBe(REDACTED)
expect(redact(["rk", "live", body].join("_"), rules)).toBe(REDACTED)
})

test("value patterns match PEM private key blocks", () => {
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
const pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----"
const result = redact(pem, rules)
expect(result).toBe(REDACTED)

const openssh = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjE=\n-----END OPENSSH PRIVATE KEY-----"
expect(redact(openssh, rules)).toBe(REDACTED)

const generic = "-----BEGIN PRIVATE KEY-----\nMIIEv...\n-----END PRIVATE KEY-----"
expect(redact(generic, rules)).toBe(REDACTED)
})

test("GitHub PAT pattern covers all prefix variants", () => {
const rules: McpRedactionRules = { valuePatterns: DEFAULT_REDACTION_RULES.valuePatterns }
// Built at runtime so GitHub secret scanning doesn't flag the test file.
const body = "X".repeat(36)
for (const prefix of ["ghp", "ghs", "gho", "ghu", "ghr"]) {
expect(redact(`${prefix}_${body}`, rules)).toBe(REDACTED)
}
})
})

describe("form-urlencoded body redaction", () => {
test("redacts sensitive values in form-encoded body strings", () => {
const rules: McpRedactionRules = {
sensitiveKeys: ["password", "token"],
valuePatterns: [],
}
const body = "username=alice&password=s3cret&token=abc123&remember=1"
const result = redact(body, rules)
expect(result).toBe(`username=alice&password=${REDACTED}&token=${REDACTED}&remember=1`)
})

test("redacts form-encoded inside a request.data string", () => {
const rules: McpRedactionRules = {
sensitiveKeys: ["password"],
valuePatterns: [],
}
const data = {
request: {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
data: "user=alice&password=hunter2",
},
}
const result = redact(data, rules) as any
expect(result.request.data).toBe(`user=alice&password=${REDACTED}`)
})

test("does not false-positive on casual strings with '=' ", () => {
const rules: McpRedactionRules = {
sensitiveKeys: ["password"],
valuePatterns: [],
}
// Must NOT be treated as form-encoded — it's just prose with an equals sign.
const data = { note: "The setting x=5 was applied, and the count is 3" }
const result = redact(data, rules) as any
expect(result.note).toBe("The setting x=5 was applied, and the count is 3")
})

test("does not alter form-encoded strings when no sensitive key matches", () => {
const rules: McpRedactionRules = {
sensitiveKeys: ["password"],
valuePatterns: [],
}
const body = "page=1&limit=10&sort=desc"
expect(redact(body, rules)).toBe(body)
})

test("URL with query params still uses URL path, not form-encoded path", () => {
// Regression guard: a string containing '?' should go through the URL branch,
// not the form-encoded branch.
const rules: McpRedactionRules = {
sensitiveKeys: ["token"],
valuePatterns: [],
}
const url = "https://api.example.com/x?token=abc&page=1"
expect(redact(url, rules)).toBe(`https://api.example.com/x?token=${REDACTED}&page=1`)
})

test("handles bracketed and percent-encoded keys", () => {
const rules: McpRedactionRules = {
sensitiveKeys: ["password"],
valuePatterns: [],
}
const body = "user[name]=alice&password=hunter2"
const result = redact(body, rules)
expect(result).toBe(`user[name]=alice&password=${REDACTED}`)
})
})