--- name: "Splunk: Redact Secrets and Emails from Responses" tags: - splunk - observability - secrets - pii - redaction - egress publishedAt: 2026-10-11 description: | # splunk / redact-secrets-egress **Direction:** egress (`tool_post_invoke`) **Default:** allow (transform-only, never denies) **Package:** `splunk.egress.redact_secrets` ## What it does Logs are where secrets actually leak: a debug line that prints a request header, a stack trace with a connection string, a config dump at startup. This policy masks credentials and email addresses in every Splunk response before the text reaches the agent, so they never end up in the model's context, a chat transcript, or a ticket the agent writes. - **Credentials** are replaced with `[REDACTED-SECRET]`: `password=` / `token:` / `api_key=` style pairs, AWS access keys and secret keys, GitHub and GitLab tokens, Slack tokens, Stripe live keys, Google API keys, OpenAI-style keys, PEM private keys, JWTs, `Bearer` tokens, and the password part of connection strings (`postgres://app:[REDACTED-SECRET]@db`). - **Email addresses** are replaced with `[REDACTED-EMAIL]`. - Everything else in the response is returned unchanged. JSON responses keep their structure: only string values are rewritten, and a match never runs past the end of the value it's in. If nothing matches, the response is passed through byte for byte. Applies to every tool on the Splunk MCP server — `splunk_run_query`, `splunk_run_saved_search`, alert details, and the `saia_` / `o11y_` tools — since any of them can return raw events. **Quoted values and credential fields.** `key="value"` and `key: 'value'` pairs are masked the same as unquoted ones, including JSON-style `"password": "..."` inside a log line or comment. In JSON responses, a string under a credential-named field (`password`, `apiKey`, `client_secret`, `accessToken`, `db.password`, …) is masked whatever its value looks like. Pagination cursors such as `nextPageToken` are left alone. ## Compliance alignment - **SOC 2 CC6.7** — restricts movement of confidential information (credentials) into a new system (the agent's context). - **GDPR Art. 5(1)(c), Art. 32** — data minimisation and security of processing for personal data (email addresses) found in logs. ## Why egress The secrets are already in Splunk; the only place to stop them is on the way back to the agent. Only the response is rewritten — the data in Splunk is untouched. ## Examples ### Redacted ```jsonc { "input": { "action": "tool_post_invoke", "mode": "output", "resource": { "name": "splunk-splunk_run_query", "type": "tool" }, "payload": { "text": [ "ERROR auth failed for jane@acme.com token=abc123def456" ] } } } ``` `transform.transformed_payload.text` = `["ERROR auth failed for [REDACTED-EMAIL] [REDACTED-SECRET]"]`. ## Composition Pairs with [`splunk/block-write-commands`](../block-write-commands/policy.md) and [`splunk/cap-search`](../cap-search/policy.md). The same pattern set is used by [`datadog/redact-secrets-egress`](../../datadog/redact-secrets-egress/policy.md) and, for credentials, [`github/redact-secrets-egress`](../../github/redact-secrets-egress/policy.md). ## Known limitations - **Server name prefix.** Scope is tools whose name starts with `splunk`. Change `_is_splunk` if your server is registered under another name. - **Known shapes only.** Secrets that don't match a listed shape (custom tokens, short hex keys) pass through. Add your providers' formats to `secret_patterns`. - **Re-serialized JSON.** When a JSON response is changed, it's returned minified with keys sorted. The content is the same. - **Emails are always masked.** If your investigations need email addresses (for example, tracing one user's session), drop the email pattern or scope the policy more narrowly. > **Compliance note.** This policy supports alignment with the cited framework controls **on the MCP path only**. No policy or bundle makes an organization compliant with any framework; web-UI, native-API, and in-app access are outside the gateway's reach by design. Validate against your own compliance program before relying on it. direction: egress apps: - splunk industries: [] bundles: - engineering-stack-jira - engineering-stack-linear experimental: true schemaVersion: 1.0.0 minimumGatewayVersion: 1.0.0b24 --- ```rego package splunk.egress.redact_secrets # Transform-only egress policy — never denies. Masks credentials and email # addresses in responses before they enter agent context. JSON responses keep their structure: only # string values are rewritten. default allow := true secret_marker := "[REDACTED-SECRET]" email_marker := "[REDACTED-EMAIL]" email_pattern := `[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}` # Credential shapes, applied in order. JWT and Bearer come before the generic # key=value pair so a token is masked whole. The generic pair takes a quoted # value ("..." or '...', on one line) or an unquoted one that stops at quotes, # commas, and semicolons so it never eats into neighbouring text. The key may be # quoted and carry a prefix (`"db_password": "x"`, `client.secret='x'`). secret_patterns := [ # JSON Web Tokens `eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`, # Bearer tokens `(?i)bearer\s+[A-Za-z0-9._~+/-]{16,}=*`, # Generic password / token / api_key / secret in `key: value` or `key=value` form, # quoted or not `(?i)["']?[A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|secret[_-]?key|access[_-]?key|client[_-]?secret)["']?\s*[:=]\s*(?:"[^"\r\n]+"|'[^'\r\n]+'|[^\s"'<>,;]+)`, # AWS access key IDs (long-term and temporary) `(?:AKIA|ASIA)[0-9A-Z]{16}`, # AWS secret access keys (40-char base64-ish, anchored near the word "aws") `(?i)aws(.{0,20})?(secret|access)?.{0,20}[\s:=]+[A-Za-z0-9/+=]{40}`, # GitHub tokens (personal, OAuth, user, server, refresh) and fine-grained PATs `gh[pousr]_[A-Za-z0-9]{36}`, `github_pat_[A-Za-z0-9_]{82}`, # GitLab personal access tokens `glpat-[A-Za-z0-9_-]{20}`, # Slack tokens `xox[baprs]-[A-Za-z0-9-]{10,}`, # Stripe live secret and restricted keys `(?:sk|rk)_live_[A-Za-z0-9]{24,}`, # Google API keys `AIza[0-9A-Za-z\-_]{35}`, # OpenAI-style API keys `sk-[A-Za-z0-9_-]{20,}`, # PEM private key blocks, then any stray header `-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----`, `-----BEGIN [A-Z ]*PRIVATE KEY-----`, ] # Passwords embedded in connection strings (postgres://user:pass@host): keep the # scheme and user, mask the password. conn_string_pattern := `(?i)\b([a-z][a-z0-9+.-]*://[^\s:/@"']+:)[^\s@/"']+@` # --- Redaction of one string (Rego has no recursion, so the fold is a chain) --- _redact(t) := out if { r0 := regex.replace(t, secret_patterns[0], secret_marker) r1 := regex.replace(r0, secret_patterns[1], secret_marker) r2 := regex.replace(r1, secret_patterns[2], secret_marker) r3 := regex.replace(r2, secret_patterns[3], secret_marker) r4 := regex.replace(r3, secret_patterns[4], secret_marker) r5 := regex.replace(r4, secret_patterns[5], secret_marker) r6 := regex.replace(r5, secret_patterns[6], secret_marker) r7 := regex.replace(r6, secret_patterns[7], secret_marker) r8 := regex.replace(r7, secret_patterns[8], secret_marker) r9 := regex.replace(r8, secret_patterns[9], secret_marker) r10 := regex.replace(r9, secret_patterns[10], secret_marker) r11 := regex.replace(r10, secret_patterns[11], secret_marker) r12 := regex.replace(r11, secret_patterns[12], secret_marker) r13 := regex.replace(r12, secret_patterns[13], secret_marker) r14 := regex.replace(r13, conn_string_pattern, concat("", ["${1}", secret_marker, "@"])) out := regex.replace(r14, email_pattern, email_marker) } # --- Redaction of every string inside a JSON value, keeping its structure --- _is_container(x) if is_object(x) _is_container(x) if is_array(x) _escape_segment(seg) := replace(replace(sprintf("%v", [seg]), "~", "~0"), "/", "~1") _pointer(path) := concat("", [concat("", ["/", _escape_segment(seg)]) | some seg in path]) # Field names that hold credentials. Compared lowercased with `_`, `-` and `.` # removed, so apiKey, api_key, client-secret, db.password and accessToken all # match. Pagination and sync cursors (nextPageToken, cursor_token) are not # credentials and are left alone. _credential_field_suffixes := { "password", "passwd", "passphrase", "secret", "token", "apikey", "accesskey", "secretkey", "privatekey", "credential", "credentials", } _cursor_words := {"page", "cursor", "next", "sync", "continuation"} _credential_field(k) if { is_string(k) n := replace(replace(replace(lower(k), "_", ""), "-", ""), ".", "") some s in _credential_field_suffixes endswith(n, s) not _is_cursor_field(n) } _is_cursor_field(n) if { some w in _cursor_words contains(n, w) } # A non-empty string under a credential-named field is masked whole, whatever # its value looks like. Any other string goes through the shape patterns. _redact_at(path, v) := secret_marker if { count(path) > 0 _credential_field(path[count(path) - 1]) v != "" } else := _redact(v) _patches_for(doc) := [{"op": "replace", "path": _pointer(path), "value": r} | walk(doc, [path, v]) is_string(v) r := _redact_at(path, v) r != v ] # --- Scope --- _is_egress if input.mode == "output" _is_egress if input.action == "tool_post_invoke" _names contains lower(object.get(object.get(input, "resource", {}), "name", "")) _names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", "")) _in_scope if { some n in _names startswith(n, "splunk") } # --- Response blocks --- _json_container(b) := doc if { is_string(b) json.is_valid(b) doc := json.unmarshal(b) _is_container(doc) } # JSON text with secrets: rewrite the string values, re-serialize. _redact_block(b) := json.marshal(json.patch(doc, ps)) if { doc := _json_container(b) ps := _patches_for(doc) count(ps) > 0 } # JSON text without secrets: unchanged. _redact_block(b) := b if { doc := _json_container(b) count(_patches_for(doc)) == 0 } # Plain text. _redact_block(b) := _redact(b) if { is_string(b) not _json_container(b) } # Structured (non-string) blocks. _redact_block(b) := json.patch(b, _patches_for(b)) if _is_container(b) _redact_block(b) := b if { not is_string(b) not _is_container(b) } _text := object.get(object.get(input, "payload", {}), "text", []) _redacted := [out | some b in _text; out := _redact_block(b)] transform := { "transformed_payload": object.union(object.get(input, "payload", {}), {"text": _redacted}), } if { _is_egress _in_scope is_array(_text) _redacted != _text } ```