---
name: "Slack: Mask Card Numbers in Message and Search Responses"
tags:
  - slack
  - mask-pan-egress
  - egress
  - cardholder-data
  - dlp
  - pci-dss
  - soc2
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # slack / mask-pan-egress

  **Direction:** egress (`tool_post_invoke`)
  **Default:** allow (transform-only — never denies)
  **Package:** `slack.egress.mask_pan`

  ## What it does

  Masks payment-card numbers (PANs) in Slack content returned to agents by
  message-read, thread-read, canvas-read, history, and search tools. Humans
  type card numbers into chat — DMs with customers, support channels, order
  threads — and every read of that history would otherwise place the full PAN
  into the agent's context. This policy Luhn-validates every 13–19-digit
  card-shaped sequence in the response and rewrites each match to
  **BIN-plus-last4**: the first six digits (the issuer BIN) and last four are
  kept, and every digit in between becomes `*`, e.g.
  `4111 1111 1111 1111` → `411111******1111`. BIN+last4 is the maximum
  display format PCI DSS permits for personnel without a business need to
  see full PAN.

  The policy never blocks a call. When at least one PAN is found the response
  content blocks are rewritten via `transformed_payload`; when nothing
  matches, the transform rule is undefined and the response passes through
  byte-identical.

  Callers whose `input.subject.claims.groups` contains the documented
  placeholder group `pci-full-pan` receive unmasked responses. The exemption
  is fail-closed: a caller with no subject, no claims, no `groups` claim, or
  a malformed `groups` claim is never exempt and always gets masked output.

  ## Compliance alignment

  - **PCI DSS 3.4.1** — supports masking of PAN when displayed: the agent
    channel shows at most BIN+last4, with full-PAN visibility limited to a
    defined role (`pci-full-pan`).
  - **PCI DSS 3.4.2** — supports preventing copy/relocation of PAN via
    remote-access technologies: an agent that only ever receives the masked
    form cannot re-post the full PAN into other channels, tickets, or files.
  - **PCI DSS 12.10.7** — supports PAN-where-not-expected incident
    procedures: chat is a classic not-expected location, and the gateway's
    decision/transform audit events for this policy give the incident
    process a concrete trigger to work from.
  - **SOC 2 CC6.7** — supports the restriction on transmission/movement of
    confidential information: cardholder data read back from Slack does not
    move into agent context in full.
  - **CCPA/CPRA §1798.150** — supports reducing nonredacted-PI breach
    exposure: card numbers surfaced to agents from chat history are masked
    by default.

  ## Tool name matching

  The policy matches content-returning Slack read tools case-insensitively
  by suffix on `input.resource.name`, after normalizing `_` to `-` so both
  underscore (as the servers publish them) and hyphenated (as some gateways
  deliver them) forms match. It covers all three Slack MCP server
  vocabularies in real use:

  - **Official Slack MCP server** (`mcp.slack.com`, what the Claude
    connector uses): `slack_read_channel`, `slack_read_thread`,
    `slack_read_canvas`, `slack_search_public`,
    `slack_search_public_and_private`.
  - **korotovsky/slack-mcp-server**: `conversations_history`,
    `conversations_replies`, `conversations_search_messages`,
    `conversations_unreads`, `saved_list` (the last two also return message
    bodies, so they are in scope for masking).
  - **Archived reference server** (still widely forked):
    `slack_get_channel_history`, `slack_get_thread_replies`.

  Directory tools (`slack_search_channels`, `slack_search_users`,
  `slack_read_user_profile`, `channels_list`, …) return metadata, not
  message bodies, and are deliberately out of scope — see the companion
  profile-PII policy in Composition.

  The DTwo gateway prefixes tool names with the configured MCP server name
  (e.g. `slack-mcp-slack_read_channel`), and that prefix is not
  standardized — suffix matching keeps the policy portable. The official
  server's names are observed-current, not contractual (Slack documents
  `tools/list` as the source of truth and says names can change), so verify
  the exact names your gateway sends with the dump-input debug technique
  before relying on this in production.

  ## Patterns matched

  Conservative, anchored PAN shapes only — each pattern is commented in the
  Rego, and every candidate must also pass the Luhn check before it is
  masked, which keeps false positives (Slack timestamps, order IDs, phone
  numbers) low:

  - 16-digit PANs grouped 4-4-4-4 with space or dash separators
    (Visa/Mastercard/Discover print format).
  - 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37
    IIN range.
  - Unseparated 13–19-digit runs (the ISO/IEC 7812 PAN length range). Runs
    of 20+ digits never match: there is no word boundary inside a digit
    run, so a longer identifier is never partially masked.

  ## Response shape

  Egress tool output arrives as content blocks in `input.payload.text` (an
  array; entries are typically strings of plain text, markdown, or
  serialized JSON). The policy scans each string block, replaces every
  Luhn-valid match with its own BIN+last4 form, and emits
  `transform.transformed_payload` with the original payload's `text`
  replaced by the masked blocks. Non-string blocks pass through unmodified.
  Because matching is string-level, PANs are masked wherever they appear —
  message bodies, search snippets, canvas markdown — without parsing each
  tool's specific JSON shape.

  ## Examples

  ### Transformed (masked)

  ```jsonc
  {
    "input": {
      "action": "tool_post_invoke",
      "mode": "output",
      "resource": { "name": "slack-mcp-slack_read_thread", "type": "tool" },
      "payload": {
        "name": "slack-mcp-slack_read_thread",
        "text": ["customer: my card is 4111 1111 1111 1111, exp 12/27"]
      },
      "subject": { "sub": "google-apps|casey@acme.com", "claims": { "groups": ["support"] } }
    }
  }
  ```

  `allow = true`; the agent sees
  `customer: my card is 411111******1111, exp 12/27`.

  ### Allowed unmasked (exempt group)

  ```jsonc
  {
    "input": {
      "action": "tool_post_invoke",
      "mode": "output",
      "resource": { "name": "slack-mcp-slack_read_thread", "type": "tool" },
      "payload": {
        "name": "slack-mcp-slack_read_thread",
        "text": ["customer: my card is 4111 1111 1111 1111, exp 12/27"]
      },
      "subject": { "sub": "google-apps|pci-analyst@acme.com", "claims": { "groups": ["pci-full-pan"] } }
    }
  }
  ```

  `allow = true`, no transform — the caller is in the `pci-full-pan` group.

  ### Passthrough (no PAN)

  A Luhn-invalid digit run (a Slack message timestamp, an order number)
  produces no transform; the response is returned byte-identical.

  ## Composition

  First egress policy for Slack — the five existing Slack policies are all
  ingress. One policy, one job; useful companions:

  - [`redact-sensitive-info`](../redact-sensitive-info/policy.md) (ingress)
    covers the opposite direction: it redacts card numbers and other
    sensitive shapes from messages the agent *writes* into Slack. Attach
    both for round-trip coverage.
  - [`deny-read-search-summarize-sensitive-channels`](../deny-read-search-summarize-sensitive-channels/policy.md)
    and [`guard-dm-privacy`](../guard-dm-privacy/policy.md) (ingress) stop
    the highest-risk reads outright; this policy masks card data in the
    reads you do allow.
  - A profile-PII egress policy (PF-02 style) for
    `slack_read_user_profile` / `slack_search_users` responses — directory
    PII is a separate concern from cardholder data, with a different
    exemption group.

  ## Known limitations

  - **Luhn-valid non-card numbers are masked too.** The Luhn check
    eliminates most timestamps and IDs, but some non-card identifiers
    (certain IMEIs and other checksummed numbers) are Luhn-valid and will
    be masked. The masked form keeps first-six/last-four, so such false
    positives usually stay recognizable.
  - **Obfuscated PANs are missed.** Card numbers with separators other
    than space/dash (dots, unicode spaces), split across lines or content
    blocks, spelled out in words, or base64-encoded do not match. Card
    numbers typed with non-ASCII digits (e.g. Unicode fullwidth
    `４１１１ １１１１ １１１１ １１１１`) also do not match: the RE2 `\d`
    class is ASCII-only, so fullwidth/other Unicode digit codepoints are
    never seen as digits. Grouped formats other than 4-4-4-4 and Amex 4-6-5
    (e.g. 19-digit 4-4-4-4-3 print format) match only in their unseparated
    form.
  - **A PAN glued directly to a word character is missed.** Every pattern
    is `\b`-anchored, and the underscore counts as a word character in RE2,
    so a digit run immediately preceded or followed by a letter, digit, or
    underscore with no separator (e.g. `acct_4111111111111111` or
    `card4111111111111111x` inside a serialized-JSON token value) has no
    word boundary and is not masked. This is the deliberate cost of the
    same `\b` anchoring that stops a 20+-digit identifier from being
    partially masked — dropping the anchor would trade this evasion for
    false partial-masking of longer numbers. Punctuation- or
    whitespace-delimited PANs (the normal human-typed case) are unaffected.
  - **Adjacent digit groups can shadow a grouped PAN.** In pathological
    sequences like `1234 5678 4111 1111 1111 1111`, the leftmost 4-4-4-4
    window is consumed first (and fails Luhn), so the real PAN inside it
    is not matched. Unseparated PANs are unaffected.
  - **Substring collisions between two detected PANs.** Replacements are
    applied per distinct matched string in unspecified order; if one
    detected PAN is a literal substring of another in the same block
    (both Luhn-valid), more than BIN+last4 of the longer one can remain
    visible. Middle digits of every match still get masked.
  - **Structured (non-string) content blocks and non-array `text` are not
    masked — fail-open.** The policy scans and rewrites only string entries
    of `input.payload.text`, and only when `text` is a JSON array. A PAN
    carried inside a content block delivered as a JSON *object* (e.g. an MCP
    typed block `{"type":"text","text":"…4111 1111 1111 1111…"}`) passes
    through unmasked, and if a server delivers `payload.text` as a bare
    string instead of an array the transform never fires — in both cases the
    response is returned byte-identical and the PAN reaches the agent. In the
    DTwo egress shape observed to date tool output arrives as an array of
    *string* blocks, and serialized JSON inside a string block **is** scanned
    and masked; only native object shapes and non-array `text` evade it.
    Confirm with the dump-input technique that your gateway/server delivers
    string blocks before relying on this policy against servers that emit
    typed content objects — pair with a schema-aware egress transform if
    yours does.
  - **Egress masking only.** The full card number still exists in Slack
    itself and in Slack's own UI; this policy controls what the *agent*
    sees on the MCP path. Pair with the ingress `redact-sensitive-info`
    policy to keep agents from writing card numbers into Slack.
  - **Tool names are observed, not contractual.** The official Slack MCP
    server publishes exact tool names only at runtime; the names matched
    here are corroborated from the landscape research but may change.
    korotovsky and reference-server names are verified from their
    README/source. Emoji/file/reaction capabilities of the official server
    have no verifiable tool names and are not covered.
  - **Group names are placeholders** — replace `pci-full-pan` with your
    IdP's group name at import time. The exemption reads
    `input.subject.claims.groups` and requires it to be an **array** of
    strings; every other shape (string, object, number, null, or missing)
    fails closed to masked output. Confirm your IdP emits a `groups` claim
    as a string array for your tenant before relying on the exemption.

  > **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:
  - slack
industries: []
bundles:
  - pci-dss
  - soc2
  - gdpr-ccpa
experimental: true
schemaVersion: 1.0.0
minimumGatewayVersion: 1.0.0b24
---

```rego
package slack.egress.mask_pan

# Transform-only policy — never denies, only masks Luhn-valid card numbers
# in Slack read/search/history responses to BIN+last4.
default allow := true

# -----------------------------------------------------------------------------
# Tool matching — content-returning Slack read tools across the three MCP
# server vocabularies in real use. The gateway prefixes tool names with the
# configured server name, so we match on the suffix to stay portable.
# Suffixes are hyphenated; the incoming name is normalized `_` -> `-` first so
# both `slack_read_channel` and `slack-read-channel` deliveries match.
# -----------------------------------------------------------------------------

content_read_suffixes := [
    # Official Slack MCP server (mcp.slack.com — used by the Claude connector).
    "slack-read-channel",
    "slack-read-thread",
    "slack-read-canvas",
    "slack-search-public",
    "slack-search-public-and-private",
    # korotovsky/slack-mcp-server (community).
    "conversations-history",
    "conversations-replies",
    "conversations-search-messages",
    # korotovsky content-returning reads that also carry message bodies:
    # unread messages and the saved-items list both return message text.
    "conversations-unreads",
    "saved-list",
    # Archived reference server (deprecated but still widely forked).
    "slack-get-channel-history",
    "slack-get-thread-replies",
]

normalized_name := replace(lower(input.resource.name), "_", "-")

is_content_read_tool if {
    some suffix in content_read_suffixes
    endswith(normalized_name, suffix)
}

# -----------------------------------------------------------------------------
# PAN candidate shapes — anchored with \b word boundaries so digit runs inside
# longer identifiers are never partially matched. Every candidate must also
# pass the Luhn check below before it is masked.
# -----------------------------------------------------------------------------

pan_pattern := concat("|", [
    # 16-digit PANs grouped 4-4-4-4 with space or dash separators
    # (Visa / Mastercard / Discover print format, e.g. 4111 1111 1111 1111).
    `\b\d{4}[ -]\d{4}[ -]\d{4}[ -]\d{4}\b`,
    # 15-digit American Express PANs grouped 4-6-5 with space or dash
    # separators, constrained to the 34/37 IIN range (e.g. 3782 822463 10005).
    `\b3[47]\d{2}[ -]\d{6}[ -]\d{5}\b`,
    # Unseparated 13-19 digit runs — the ISO/IEC 7812 PAN length range.
    # Runs of 20+ digits never match: there is no word boundary inside a
    # digit run, so this cannot partially mask a longer identifier.
    `\b\d{13,19}\b`,
])

# -----------------------------------------------------------------------------
# Luhn check — filters card-shaped candidates so timestamps, order numbers,
# and other digit runs that merely look like PANs are left alone.
# -----------------------------------------------------------------------------

digits_only(s) := regex.replace(s, `[^0-9]`, "")

luhn_contribution(d, parity) := d if { parity == 0 }

luhn_contribution(d, parity) := 2 * d if {
    parity == 1
    (2 * d) < 10
}

luhn_contribution(d, parity) := (2 * d) - 9 if {
    parity == 1
    (2 * d) >= 10
}

luhn_valid(digits) if {
    chars := split(digits, "")
    n := count(chars)
    total := sum([v |
        some i, c in chars
        v := luhn_contribution(to_number(c), (n - 1 - i) % 2)
    ])
    total % 10 == 0
}

# All card-shaped substrings of t that pass the Luhn check.
pan_candidates(t) := {c |
    some c in regex.find_n(pan_pattern, t, -1)
    luhn_valid(digits_only(c))
}

# -----------------------------------------------------------------------------
# Masking — each match is rewritten to BIN+last4: first six digits (issuer
# BIN) and last four kept, everything between masked with `*`. Separators are
# dropped in the masked form (e.g. `4111 1111 1111 1111` -> `411111******1111`).
# -----------------------------------------------------------------------------

mask_pan(c) := masked if {
    d := digits_only(c)
    n := count(d)
    masked := concat("", [
        substring(d, 0, 6),
        # Replace every middle digit with `*` (RE2 has no repeat builtin, so we
        # mask the middle substring char-by-char instead of building a `*` run).
        regex.replace(substring(d, 6, n - 10), `\d`, "*"),
        substring(d, n - 4, 4),
    ])
}

# Rewrite every Luhn-valid candidate in a string block to its masked form.
mask_block(b) := out if {
    is_string(b)
    replacements := {c: mask_pan(c) | some c in pan_candidates(b)}
    count(replacements) > 0
    out := strings.replace_n(replacements, b)
}

mask_block(b) := b if {
    is_string(b)
    count(pan_candidates(b)) == 0
}

# Non-string content blocks (structured/JSON blocks) pass through unmodified.
mask_block(b) := b if { not is_string(b) }

# -----------------------------------------------------------------------------
# Full-PAN exemption — callers in the placeholder group see unmasked content.
# Fail-closed: missing subject, missing claims, missing groups, or a malformed
# groups claim all leave this rule undefined, so masking applies. The
# is_array guard is load-bearing: without it a groups claim shaped as an
# object (e.g. {"role":"pci-full-pan"}) would iterate its *values* and match,
# granting the exemption to a caller who never held the group in an array.
# Requiring an array keeps every non-array shape (string, object, number,
# null) fail-closed. Replace "pci-full-pan" with your IdP's group name at
# import time.
# -----------------------------------------------------------------------------

caller_may_view_full_pan if {
    claims := object.get(object.get(input, "subject", {}), "claims", {})
    groups := object.get(claims, "groups", [])
    is_array(groups)
    some group in groups
    group == "pci-full-pan"
}

# -----------------------------------------------------------------------------
# Transform — emitted only when in scope, the caller is not exempt, 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(input.payload, "text", [])

masked_blocks := [out |
    some block in text_blocks
    out := mask_block(block)
]

transform := {
    "transformed_payload": object.union(input.payload, {"text": masked_blocks}),
} if {
    input.mode == "output"
    is_content_read_tool
    not caller_may_view_full_pan
    is_array(text_blocks)
    masked_blocks != text_blocks
}
```
