Zapier: Mask Card Numbers in Read Responses
Masks payment-card numbers (PANs) in Zapier MCP read responses before they reach the agent.
- Direction
- egress
- Rego package
zapier.egress.mask_pan- App
- zapier
- Bundles
- soc2pci-dssgdpr-ccpa
- Published
- Minimum gateway
- 1.0.0b24
- Schema version
- 1.0.0
- Checksum
sha256:3bbdf8be613466767f4826a268a3eac6cbee33de02de1da55e80f11ce2c117bd
zapiermask-pan-egressegresscardholder-datadlpsoc2pci-dssgdpr-ccpa
What this policy does
Direction: egress (tool_post_invoke)
Default: allow (transform-only — never denies)
Package: zapier.egress.mask_pan
What it does
Masks payment-card numbers (PANs) in Zapier MCP read responses before they
reach the agent. Zapier is an aggregator: one connector proxies reads
across 9,000+ apps, including finance-adjacent ones (QuickBooks, Stripe,
NetSuite) whose records routinely carry card numbers — and the aggregator
applies no source-app DLP, so an execute_zapier_read_action response
or a classic-mode *_find_* / *_get_* response can deliver a full PAN
straight into the agent's context.
This policy Luhn-validates every 13–19-digit card-shaped sequence in the
response text 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. All other response content is left intact.
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 — non-matching reads are unaffected.
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
- SOC 2 CC6.7 — supports the restriction on transmission/movement of information: card numbers are reduced to BIN+last4 before the response leaves the gateway, so full PAN never moves into the agent's context or onward to any of the 9,000 apps the same Zapier connector can write to.
- PCI DSS 3.4.1 — supports masking of PAN when displayed: the Zapier
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 chat, tickets, files, or any of the 9,000 apps the same Zapier connector can write to.
- PCI DSS 12.5.2 — supports scope documentation/confirmation as a scope-creep backstop: the aggregator's read funnel is a classic path by which cardholder data silently expands PCI scope into agent context; masking at the gateway keeps that path out of scope by default.
- PCI DSS 12.10.7 — supports PAN-where-not-expected incident procedures: agent context fed by a general-purpose aggregator is a not-expected location, and the gateway's decision/transform audit events for this policy give the incident process a concrete trigger.
- CCPA/CPRA §1798.150 — supports reducing nonredacted-PI breach exposure: card numbers surfaced to agents through the Zapier funnel are masked by default.
Tool name matching
Zapier MCP runs in one of two mutually exclusive modes per server, with
disjoint tool namespaces. This policy covers the read path of both,
matching case-insensitively on input.resource.name after normalizing
_ to - so underscore (as Zapier publishes them) and hyphenated
deliveries both match:
- Agentic mode (default): the single read funnel
execute_zapier_read_action(verified name), matched by suffix. Every enabled read action across every connected app returns through this one tool. - Classic (manual-configuration) mode: per-action tools named
<app>_<action>where read actions usefind_/get_verbs (e.g.quickbooks_online_find_customer, a verified example). Matched by the normalized name containing-find-or-get-.
The DTwo gateway prefixes tool names with the configured MCP server name
(e.g. zapier-mcp-execute_zapier_read_action), and that prefix is not
standardized — suffix/substring matching keeps the policy portable. Verify
the exact names your gateway sends with the dump-input debug technique
before relying on this in production.
Two agentic-mode meta-tools, get_configuration_url and
get_zapier_skill, incidentally match the -get- rule. That is harmless
by construction: the policy is transform-only, and a Luhn-valid PAN inside
stored skill text would be worth masking anyway.
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 (order IDs, phone numbers, invoice numbers, long identifiers) 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 or serialized JSON —
Zapier read results are usually JSON records from the source app). 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. Because matching
is string-level, PANs are masked wherever they appear in the serialized
record — field values, nested objects, free-text notes — without parsing
each source app's specific JSON shape. Non-string blocks pass through
unmodified.
Examples
Transformed (masked)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "zapier-mcp-execute_zapier_read_action", "type": "tool" },
"payload": {
"name": "zapier-mcp-execute_zapier_read_action",
"text": ["{\"customer\":\"Acme\",\"card_on_file\":\"4111 1111 1111 1111\"}"]
},
"subject": { "sub": "auth0|casey@acme.com", "claims": { "groups": ["support"] } }
}
}
allow = true; the agent sees
{"customer":"Acme","card_on_file":"411111******1111"}.
Allowed unmasked (exempt group)
{
"input": {
"action": "tool_post_invoke",
"mode": "output",
"resource": { "name": "zapier-mcp-quickbooks_online_find_customer", "type": "tool" },
"payload": {
"name": "zapier-mcp-quickbooks_online_find_customer",
"text": ["card on file: 4111 1111 1111 1111"]
},
"subject": { "sub": "auth0|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 (an order number, a tracking ID, an epoch timestamp) produces no transform; the response is returned byte-identical.
Composition
One policy, one job — this masks card numbers on the read path only. Useful companions for the Zapier connector:
freeze-toolset(ingress) stops the agent from enabling new Zapier actions mid-session — without it, the read surface this policy covers can silently grow.- An ingress app-blocklist policy on the
execute_zapier_*_actionfunnel (PF-14 style) that denies finance apps outright for non-finance groups — this policy masks card data in the reads you do allow. - A PF-02-style PII redaction egress policy for SSNs, emails, and phone numbers — separate concern, separate exemption group.
Known limitations
- Write-funnel responses are not scanned.
execute_zapier_write_action(and classic*_send_*/*_create_*/*_update_*tools) can echo the written record — including card fields — back in the response, and this policy does not cover them. Coverage is deliberately the read path per the family spec; if your enabled write actions echo cardholder data, attach a widened copy that also matches the write funnel. - Classic-mode read tools without
find/getverbs are missed. Thefind_/get_verb convention comes from the landscape research and the verified examplequickbooks_online_find_customer; classic-mode names are per-account, and most were not verifiable from public docs. If your server exposes read actions with other verbs (search_,list_,lookup_), add those shapes to the tool-matching rules. - Zapier's own cloud logs are outside the gateway's reach. The raw, unmasked response transits and is logged in Zapier's cloud (per-app OAuth happens Zapier-side); this policy controls only what reaches the agent on the MCP path. The full card number also still exists in the source app (QuickBooks, Stripe, NetSuite) itself.
- Luhn-valid non-card numbers are masked too. The Luhn check eliminates most order IDs and timestamps, 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
4111 1111 1111 1111) also do not match: the RE2\dclass is ASCII-only. Grouped formats other than 4-4-4-4 and Amex 4-6-5 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_4111111111111111inside a serialized-JSON token value) has no word boundary and is not masked. This is the deliberate cost of the same\banchoring that stops a 20+-digit identifier from being partially masked. Quote-, punctuation-, or whitespace-delimited PANs (the normal JSON field-value case) are unaffected. - Structured (non-string) content blocks and non-array
textare not masked — fail-open. The policy scans and rewrites only string entries ofinput.payload.text, and only whentextis a JSON array. A PAN carried inside a content block delivered as a JSON object (an MCP typed block{"type":"text","text":"…"}), or in apayload.textdelivered as a bare string, passes through unmasked. 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. Confirm with the dump-input technique before relying on this against servers that emit typed content objects. - Agentic read meta-tools other than the two
-get-ones are not scanned. Onlyexecute_zapier_read_action(by suffix) and the incidental-get-matchesget_zapier_skill/get_configuration_urlare covered in agentic mode. The other agentic read tools —list_zapier_skills,list_enabled_zapier_actions, anddiscover_zapier_actions— contain neither-find-nor-get-, so they are not matched. A PAN embedded in stored skill text is masked when fetched viaget_zapier_skillbut not when the same text is returned bylist_zapier_skills; likewise a PAN in an action catalog fromdiscover_zapier_actions/list_enabled_zapier_actionspasses through unmasked. These are action/skill-management surfaces, not the finance read funnel this policy targets, so the gap is by design — add those suffixes to the tool-matching rules if you need skill/catalog text masked too (weigh the over-masking of numeric action keys first). - Tool names are partially unverified.
execute_zapier_read_actionand the other agentic meta-tool names are verified from Zapier's docs;quickbooks_online_find_customeris a verified classic-mode example. All other classic-mode names — and the exact response envelope of the agentic funnel — are unverified; confirm against a live capture through the gateway before relying on this in production. - Group names are placeholders — replace
pci-full-panwith your IdP's group name at import time. The exemption readsinput.subject.claims.groupsand 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 agroupsclaim 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.
Policy source (Rego)
package zapier.egress.mask_pan
# Transform-only policy — never denies, only masks Luhn-valid card numbers
# in Zapier MCP read responses to BIN+last4.
default allow := true
# -----------------------------------------------------------------------------
# Tool matching — the Zapier read path across both server modes. The gateway
# prefixes tool names with the configured MCP server name, so we match on
# suffix/substring to stay portable. The incoming name is normalized `_` -> `-`
# first so both `execute_zapier_read_action` (as Zapier publishes it) and a
# hyphenated delivery match.
# -----------------------------------------------------------------------------
normalized_name := replace(lower(input.resource.name), "_", "-")
# Agentic mode (default): the single read funnel every enabled read action
# returns through. Verified name from Zapier's MCP docs.
is_zapier_read_tool if {
endswith(normalized_name, "execute-zapier-read-action")
}
# Classic (manual-configuration) mode: per-action tools named <app>_<action>
# where read actions use find_/get_ verbs (verified example:
# quickbooks_online_find_customer). Matched as a substring because the app
# prefix is per-account. Also incidentally matches the get_configuration_url /
# get_zapier_skill meta-tools — harmless, since the policy is transform-only.
is_zapier_read_tool if {
contains(normalized_name, "-find-")
}
is_zapier_read_tool if {
contains(normalized_name, "-get-")
}
# -----------------------------------------------------------------------------
# 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 order numbers, timestamps,
# 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_zapier_read_tool
not caller_may_view_full_pan
is_array(text_blocks)
masked_blocks != text_blocks
} Canonical source: policy.md on GitHub · raw · raw on this site (.md)
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