---
name: "GitHub: Redact Secrets from Read Responses"
tags:
  - github
  - redact-secrets
  - secrets
  - dlp
  - redaction
  - egress
  - soc2
publishedAt: 2026-07-12
description: |
  # github / redact-secrets-egress

  **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-commits` control).
  - **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 whose `get_diff` / `get_files`
    methods 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 through `get_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)

  ```jsonc
  {
    "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)

  ```jsonc
  {
    "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 a `security` group, 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_read` method is not visible on egress.** The spec targets the
    `get_diff` / `get_files` methods, but the method is an ingress argument — on
    the response path only the tool name is available. This policy therefore
    scans **all** `pull_request_read` responses (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=value` patterns are broad; a 40-char base64 string near the word `aws`,
    or any `token:`-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. Tune
    `secret_patterns` for your environment.
  - **Byte-level replacement.** `transformed_payload` is computed in Rego by
    chained `regex.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 on
    `input.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`, community
    `get_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 to `read_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.
direction: egress
apps:
  - github
industries: []
bundles:
  - soc2
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```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
}
```
