GitHub: Redact Secrets from Read Responses
Scans the responses of GitHub's crown-jewel read tools and masks known credential shapes with a fixed [REDACTED-SECRET] marker before the text enters agent…
- Direction
- egress
- Rego package
github.egress.redact_secrets- App
- github
- Bundle
- soc2
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:26bc1e1ebf9227a52389fc2a899782f2df997bef28875995cef905cdda0be9ca
githubredact-secretssecretsdlpredactionegresssoc2
What this policy does
Direction: egress (tool_post_invoke)
Default: allow (transform-only — never denies)
Package: github.egress.redact_secrets
What it does
Scans the responses of GitHub's crown-jewel read tools and masks known
credential shapes with a fixed [REDACTED-SECRET] marker before the text
enters agent context. Source code, CI logs, and diffs routinely contain keys
that were committed to a repo; this policy keeps those keys from being
surfaced verbatim to the model on the main exfiltration channel into agent
context.
It is transform-only (default allow := true): it never blocks the read
and never changes any field other than the returned text. A matched substring
is replaced in place; everything around it — file paths, line context, diff
hunks, log lines — is preserved so the response stays useful. Responses with
no matches (and every out-of-scope tool) pass through byte-identical.
The credential regex family is the same set applied by the ingress secret
block (apps/github/block-secrets-commits): AWS access-key IDs and
secret-access-keys, GitHub personal-access tokens (classic + fine-grained),
Slack tokens, Stripe live secret keys, Google API keys, OpenAI API keys, PEM
private-key headers, and generic key: value / key=value secrets. Ingress
stops a caller from committing a secret; this egress policy stops the agent
from reading back a secret that was already committed before the gateway was
in place (or through a path the gateway does not front).
| Class | Detection | Marker |
|---|---|---|
Generic key: value secret |
password/token/api_key/secret_key/client_secret etc. in key: value or key=value form (case-insensitive) |
[REDACTED-SECRET] |
| AWS access key ID | AKIA + 16 upper-alphanumeric |
[REDACTED-SECRET] |
| AWS secret access key | aws-anchored 40-char base64-ish run |
[REDACTED-SECRET] |
| GitHub PAT (classic) | ghp_ + 36 |
[REDACTED-SECRET] |
| GitHub PAT (fine-grained) | github_pat_ + 82 |
[REDACTED-SECRET] |
| Slack token | xoxb-/xoxp-/xoxa-/xoxr-/xoxs- |
[REDACTED-SECRET] |
| Stripe live secret key | sk_live_ + 24+ |
[REDACTED-SECRET] |
| Google API key | AIza + 35 |
[REDACTED-SECRET] |
| OpenAI API key | sk- + 20+ |
[REDACTED-SECRET] |
| PEM private-key header | -----BEGIN … PRIVATE KEY----- |
[REDACTED-SECRET] |
Compliance alignment
- SOC 2 CC6.7 — supports restricting the transmission/movement of
confidential information by masking credentials in GitHub content as it
leaves the gateway toward the agent (the read-surface counterpart to the
ingress
block-secrets-commitscontrol). - SOC 2 C1.1 — supports identification and protection of confidential information on the read path: committed credentials are confidential and are masked before they reach agent context.
- GDPR Art. 5(1)(f) / Art. 32 — supports security of processing: keeping authentication secrets (which can unlock systems holding personal data) out of agent context reduces the blast radius of a prompt-injection or a leaked transcript.
This is a data-masking / security-of-processing control on the main exfiltration channel into agent context.
Why egress
The secret already lives in the repo, the CI log, or the diff — there is
nothing to block at ingress, and denying the read outright would make source
code unusable to the agent. The leak happens when file-derived text is
returned to the MCP client, so the response path is the only place to catch it
while keeping the content useful. (The ingress block-secrets-commits policy
handles the write direction — stopping new secrets from being committed.)
Tool name matching
Applies on the output path — scoped when input.mode == "output",
input.action == "tool_post_invoke", or the legacy input.kind == "tool_post_invoke" holds, so redaction still fires on a gateway build that
populates only one of the three (keying on mode alone — or on action alone
where an older build emits only the legacy kind — would fail open if that
field were unset). Tools are matched case-insensitively by
suffix, so it works regardless of the MCP server-name prefix the gateway
adds (github-mcp-…, gh-prod-…, etc.). The tool name is read from all three
egress surfaces — input.resource.name, input.tool_metadata.name, and
input.payload.name — and a suffix hit on any of them puts the call in
scope, so a gateway that populates a different surface can't slip content past
the scanner.
Official server (github/github-mcp-server, names verified from the landscape
research) crown-jewel read surfaces:
get_file_contents— file bodies across every repo the grant reaches.search_code— returns matched code snippets.get_job_logs— CI/Actions logs (a frequent home for echoed secrets).pull_request_read— the consolidated PR reader whoseget_diff/get_filesmethods return code.get_commit— the single-commit reader returns the commit's file patches (added/removed lines), the same committed-secret content an agent would otherwise read throughget_file_contents. Included so the file-read scan can't be sidestepped by fetching the commit instead. Shared verbatim by the community server.
Archived community server (@modelcontextprotocol/server-github) equivalents,
matched by their granular names: get_file_contents and search_code are
shared verbatim; get_pull_request_files is the community counterpart to the
official pull_request_read(get_files) surface.
Verify the exact names your gateway emits with the dump-input debug technique before relying on this in production, and add suffixes for any other content-returning read tools your deployment exposes.
Response shape
The policy reads input.payload.text — the MCP content-block array the gateway
populates on tool_post_invoke — and rewrites each string block. Non-string
blocks (structured/JSON content) pass through unmodified. When at least one
block changes, the policy emits transform.transformed_payload containing the
original payload with the rewritten text array (all other payload keys
preserved). When nothing matches, the transform rule is undefined and the
aggregator returns the response unchanged.
Examples
Redacted (file read containing a committed AWS key)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "github-mcp-get_file_contents", "type": "tool" },
"payload": {
"name": "github-mcp-get_file_contents",
"text": ["const key = 'AKIAIOSFODNN7EXAMPLE';"]
}
}
}
allow = true, with transform.transformed_payload.text =
["const key = '[REDACTED-SECRET]';"].
Passed through (no secret in the response)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "github-mcp-search_code", "type": "tool" },
"payload": {
"name": "github-mcp-search_code",
"text": ["function add(a, b) { return a + b; }"]
}
}
}
allow = true, no transform — the response is returned byte-identical.
Composition
Single-purpose transform policy (default allow := true); it composes cleanly
with deny policies on the same egress pipeline and with the ingress secret
block. Recommended companions in apps/github:
- block-secrets-commits (ingress) — the write-direction counterpart: stops the agent from committing new secrets in the first place.
- fence-scopes-org-allowlist (ingress) — keeps agents out of third-party and personal repos, shrinking the surface this policy has to scan.
- A deny policy on
list_secret_scanning_alerts/get_secret_scanning_alert(ingress) — those tools return the locations of live leaked credentials and are best gated to asecuritygroup, not merely masked.
Known limitations
- Regex over returned text — high-signal masking, not complete DLP. Only
values matching a known shape are caught. Base64-embedded secrets,
custom-format or rotating short-lived tokens, secrets split across lines, and
keys stored in structured JSON keys (the generic pattern matches plain-text
token: value, not a"token":"value"JSON field) all pass through. Treat this as a strong first line of defense on the read path, not a guarantee that no credential reaches the model. pull_request_readmethod is not visible on egress. The spec targets theget_diff/get_filesmethods, but the method is an ingress argument — on the response path only the tool name is available. This policy therefore scans allpull_request_readresponses (a harmless superset: masking a secret echoed in a status or review-comment method is fine).- Over-redaction is possible. The AWS secret-access-key and generic
key=valuepatterns are broad; a 40-char base64 string near the wordaws, or anytoken:-prefixed value, will be masked even if it is not a live secret. Because the response is preserved except for the matched substring, the cost is a[REDACTED-SECRET]marker in otherwise-usable output. Tunesecret_patternsfor your environment. - Byte-level replacement.
transformed_payloadis computed in Rego by chainedregex.replace, so the masked text is well-formed, but the transform replaces the response payload wholesale — verify the rewrite against your gateway version with the dump-input technique and mind attachment order if other egress transforms run on the same pipeline. - No identity exemption. All callers get the same masking; this policy has
no group carve-out by design (redacting a secret is never harmful). If you
need a break-glass reader, add a separate
allow/exemption branch keyed oninput.subject.claims.groups. - Other content-returning reads remain out of scope by design. The scope is
the crown-jewel code/log/diff surfaces (
get_file_contents,search_code,get_job_logs,pull_request_read,get_commit, communityget_pull_request_files). A secret echoed in a commit message or metadata read —list_commits,search_commits,get_repository_tree— or in issue / PR-comment / discussion / gist bodies (issue_read,get_gist,get_discussion_comments, …) is not scanned and passes through verbatim. These are lower-signal secret channels than raw file/diff content, but if your repos routinely carry credentials in commit messages or issue bodies, add the relevant suffixes toread_tool_suffixes. The cost of adding one is only possible over-redaction, never a denied read.
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.
Policy source (Rego)
package github.egress.redact_secrets
# Transform-only egress policy: masks known credential shapes in the responses
# of GitHub's crown-jewel read tools with a fixed [REDACTED-SECRET] marker before
# the text enters agent context. Never denies; only rewrites matched substrings.
default allow := true
redaction_marker := "[REDACTED-SECRET]"
# -----------------------------------------------------------------------------
# Credential patterns — the SAME family the ingress secret block
# (apps/github/block-secrets-commits) uses, applied here on the read path.
# Anchored to common shapes (key=value pairs and provider-specific prefixes) to
# limit false positives.
# -----------------------------------------------------------------------------
secret_patterns := [
# Generic password / token / api_key / secret_key in `key: value` or `key=value` form
`(?i)(?:password|passwd|secret|token|api[_-]?key|secret[_-]?key|access[_-]?key|client[_-]?secret)\s*[:=]\s*\S+`,
# AWS access key IDs
`AKIA[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 classic personal access tokens
`ghp_[A-Za-z0-9]{36}`,
# GitHub fine-grained personal access tokens
`github_pat_[A-Za-z0-9_]{82}`,
# Slack tokens (xoxb-, xoxp-, xoxa-, xoxr-, xoxs-)
`xox[baprs]-[A-Za-z0-9-]{10,}`,
# Stripe live secret keys
`sk_live_[A-Za-z0-9]{24,}`,
# Google API keys
`AIza[0-9A-Za-z\-_]{35}`,
# OpenAI API keys
`sk-[A-Za-z0-9]{20,}`,
# Generic private key headers
`-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`,
]
# -----------------------------------------------------------------------------
# Scope: GitHub crown-jewel read tools whose responses carry code / logs / diffs.
# Suffix matching keeps the policy portable across gateway server-name prefixes
# and covers both the official consolidated names and the archived community
# server's granular names. Verified against the GitHub landscape research.
# -----------------------------------------------------------------------------
read_tool_suffixes := {
# Official server (github/github-mcp-server)
"get_file_contents", # file bodies (also shared verbatim by the community server)
"search_code", # matched code snippets (also shared by the community server)
"get_job_logs", # CI/Actions logs
"pull_request_read", # consolidated PR reader (get_diff / get_files methods return code)
"get_commit", # commit view returns file patches/diffs — the same committed-secret exfil channel as get_file_contents (shared by the community server)
# Archived community server granular PR-files counterpart
"get_pull_request_files",
}
# Egress scope: match the post-invoke/output path on mode, the PARC action, OR
# the legacy `kind` alias. If we keyed on input.mode alone and a gateway build
# left it unset, is_read_tool would silently fail and redaction would no-op (fail
# open, leaking content). action and kind carry the same value on current builds,
# but older gateways populate only the legacy `kind`; matching all three closes
# the fail-open surface either alias being unset would open. Ingress
# (tool_pre_invoke / mode "input") satisfies none of the branches, so it stays out.
is_egress if { input.mode == "output" }
is_egress if { input.action == "tool_post_invoke" }
is_egress if { input.kind == "tool_post_invoke" }
# The tool name is exposed on egress under resource.name (PARC), tool_metadata.name
# (legacy), and payload.name (tool-hook canonical). Collect all three and match if
# ANY carries a read-tool suffix — matching only a subset would let a gateway that
# populates a different surface slip file content past the scanner.
candidate_names contains lower(object.get(object.get(input, "resource", {}), "name", ""))
candidate_names contains lower(object.get(object.get(input, "tool_metadata", {}), "name", ""))
candidate_names contains lower(object.get(object.get(input, "payload", {}), "name", ""))
is_read_tool if {
is_egress
some suffix in read_tool_suffixes
some n in candidate_names
endswith(n, suffix)
}
# -----------------------------------------------------------------------------
# Redaction — apply every secret pattern in turn. regex.replace is total over
# strings (returns the input unchanged when the pattern does not match), so the
# chain is safe and order-independent for these disjoint shapes. Rego forbids
# recursion, so the fold is written out as an explicit chain.
# -----------------------------------------------------------------------------
redact_all(t) := out if {
r0 := regex.replace(t, secret_patterns[0], redaction_marker)
r1 := regex.replace(r0, secret_patterns[1], redaction_marker)
r2 := regex.replace(r1, secret_patterns[2], redaction_marker)
r3 := regex.replace(r2, secret_patterns[3], redaction_marker)
r4 := regex.replace(r3, secret_patterns[4], redaction_marker)
r5 := regex.replace(r4, secret_patterns[5], redaction_marker)
r6 := regex.replace(r5, secret_patterns[6], redaction_marker)
r7 := regex.replace(r6, secret_patterns[7], redaction_marker)
r8 := regex.replace(r7, secret_patterns[8], redaction_marker)
out := regex.replace(r8, secret_patterns[9], redaction_marker)
}
# String blocks are scanned; non-string (structured/JSON) blocks pass through.
redact_block(b) := redact_all(b) if { is_string(b) }
redact_block(b) := b if { not is_string(b) }
# -----------------------------------------------------------------------------
# Transform — emitted only when in scope and at least one block actually changed.
# Otherwise the rule is undefined and the aggregator skips this policy, returning
# the response byte-identical.
# -----------------------------------------------------------------------------
text_blocks := object.get(object.get(input, "payload", {}), "text", [])
redacted_blocks := [out |
some block in text_blocks
out := redact_block(block)
]
transform := {
"transformed_payload": object.union(object.get(input, "payload", {}), {"text": redacted_blocks}),
} if {
is_read_tool
is_array(text_blocks)
redacted_blocks != text_blocks
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
Used in these guides
Related policies
Airtable: Redact PII in Record Reads
Scans the responses of the Airtable record-read tools — the calls that return row fields values — and rewrites high-confidence PII shapes to a fixed…
Asana: Redact PII in Task & Comment Reads
On the Asana MCP read path, this transform scans the free-text business fields that ride back in task, comment/story, and status-update responses — notes,…
BigQuery: Redact PII in Query Results
Scans the content returned by BigQuery's result-returning tools and rewrites high-confidence PII shapes to fixed, non-recoverable redaction tokens before the…
Block Agent Email to External Recipients
Blocks agent-initiated Microsoft 365 email sends when any recipient address falls outside a corporate-domain allowlist.
Block BigQuery Exfiltration and Cross-Project Writes
Inspects the raw GoogleSQL string carried by BigQuery SQL tools and denies any statement that moves data out of the tenant's own project — even when the call…
bigqueryguard-warehouse-exportingresssqlexfiltrationsoc2pci-dssgdpr-ccpa
Block Bulk Export & External Staging (Snowflake)
Blocks Snowflake SQL-execution tool calls whose query text moves whole tables off the Snowflake perimeter — bulk export to cloud storage or a stage, and…
snowflakeguard-warehouse-sqlexportexfiltrationingresssoc2pci-dssgdpr-ccpa