dtwo Policy Store

Block Secrets in Zoom Team Chat

Blocks Zoom Team Chat send/update tool calls whose message content looks like it contains a live secret — API keys, passwords, bearer tokens, or…

Direction
ingress
Rego package
zoom.ingress.block_secrets_chat
App
zoom
Bundle
soc2
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:708b3359e784c995db961d57370b7b74dc426bb1d29c9bfca83c49fef14f885f

zoomblock-secretsdlpingresssoc2

What this policy does

Direction: ingress (tool_pre_invoke) Default: deny on match, allow otherwise Package: zoom.ingress.block_secrets_chat

What it does

Blocks Zoom Team Chat send/update tool calls whose message_content looks like it contains a live secret — API keys, passwords, bearer tokens, or PEM-formatted private keys. All other tool calls pass through unchanged.

The two guarded tools are the Team Chat write verbs:

  • *zoom_chat_message_send
  • *zoom_chat_message_update

The check runs at ingress, before the call reaches the Zoom Team Chat MCP server, so a blocked message is never posted into Team Chat history — where it would be visible to other channel members immediately and retained by channel search permanently. This is the same conservative, anchored pattern set used by the Slack block-secrets model policy, retargeted at Zoom Team Chat's argument shape and tool names.

Compliance alignment

  • SOC 2 CC6.6 — supports boundary protection against external threats by keeping credentials out of a third-party workspace (Zoom Team Chat) whose history an attacker or over-broad member could read.

All alignment is on the MCP path only (see the compliance note below).

Why ingress and not egress

Sending or updating a Team Chat message is a write with permanent, externally visible side effects — once the call reaches Zoom the message exists in the channel and may already be surfaced to other members, notifications, and Zoom's agentic search (search_zoom). Egress redaction would only mask a response to the caller, not the posted message itself. Ingress denial is the only way to actually prevent the leak.

Patterns matched

The policy uses a small set of conservative regex patterns. Adding too many patterns dramatically increases false positives, so the list is intentionally focused on high-confidence shapes:

  • password:, token:, api_key:, client_secret:, etc. in key: value or key=value form (case-insensitive)
  • Bearer <token> authorization values
  • AWS access key IDs (AKIA…) and likely secret access keys
  • GitHub personal access tokens (ghp_…, github_pat_…)
  • Slack bot/user/admin tokens (xoxb-, xoxp-, xoxa-, xoxr-)
  • Stripe live secret keys (sk_live_…)
  • Google API keys (AIza…)
  • OpenAI-style API keys (sk-…)
  • PEM private key headers (-----BEGIN … PRIVATE KEY-----)

Tune this list for your environment. If your team uses other providers (Twilio, SendGrid, Datadog, etc.), add their token shapes to secret_patterns in policy.md.

Tool name matching

Zoom's Team Chat sub-server prefixes every tool with zoom_chat_ (verified from Zoom's team-chat child skill). The DTwo gateway further prefixes tool names with the configured MCP server name, so the full name the gateway sends looks like zoom-team-chat-zoom_chat_message_send. The policy therefore matches by suffix on lower(input.resource.name):

  • *zoom_chat_message_send
  • *zoom_chat_message_update

Matching on the suffix keeps the policy portable across gateway server-name conventions. Verify the exact name your gateway sends with the dump-input debug technique before relying on this in production. Only the send/update write verbs are guarded — other zoom_chat_ tools (e.g. zoom_chat_contact_add, zoom_chat_channel_create) are out of scope for this policy and pass through unchanged; compose separate policies for those surfaces.

Argument shape

Team Chat send/update tools carry the message body in message_content (verified from the team-chat child skill: chat_session_id, message_content, message_format, and — for update — messageId). The policy reads message_content via object.get, so a call that omits it (or omits args entirely) yields an empty body and is allowed — there is nothing to leak.

Examples

Allowed

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "zoom-team-chat-zoom_chat_message_send", "type": "tool" },
    "payload": {
      "name": "zoom-team-chat-zoom_chat_message_send",
      "args": { "chat_session_id": "abc123", "message_content": "standup in 5" }
    }
  }
}

allow = true, no reason.

Denied

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "zoom-team-chat-zoom_chat_message_send", "type": "tool" },
    "payload": {
      "name": "zoom-team-chat-zoom_chat_message_send",
      "args": {
        "chat_session_id": "abc123",
        "message_content": "here's the api_key: sk-abcdef0123456789abcdef0123456789"
      }
    }
  }
}

allow = false, reason = "This Zoom Team Chat message looks like it contains a secret (...)".

Composition

This policy is single-purpose. Useful companions:

  • apps/zoom/block-external-chat-targets — deny inviting external parties into chat.
  • An egress PII/secret redaction policy on search_zoom results so any secret already in chat history is masked when read back.
  • A transform-only variant that redacts rather than blocks, for environments where rejecting the call is too disruptive.

Known limitations

  • Regex over plain text. As with the Slack model policy, this is a high-signal first line of defense, not a complete DLP solution. Secrets that don't match a known shape (rotating short-lived tokens, custom-format keys) will not be caught. A multi-line body is inspected in full — a secret on any line still denies (the patterns are unanchored and \s spans newlines) — but a secret written as prose with no key: value/key=value delimiter and no provider prefix (e.g. "the password is Hunter2…") is not caught. See the tests.yaml red-team cases that assert these residuals.
  • Encoding and obfuscation evade the patterns. A secret that is base64-encoded, hex-encoded, split across characters, or written with unicode look-alike characters in the key (e.g. fullwidth password:) will not match and is allowed through. This is inherent to any regex-over-text DLP check; do not treat this policy as a defense against a caller deliberately obfuscating a secret. Pair it with response-side redaction and human review of what agents post.
  • message_content only. The policy inspects the top-level message_content string. Team Chat's message_format and any structured/rich-content fields are not inspected — a secret placed in message_format (or any non-message_content argument) is allowed through (see the tests.yaml residual case). Extend the body-extraction rule if your deployment routinely sends secret-laden content through other fields.
  • Only send/update are guarded — other Zoom write surfaces are an open escape hatch. Other zoom_chat_ write tools are not inspected by this policy (they don't carry a free-text message body). More importantly, a secret this policy blocks from Team Chat can still be written verbatim into a Zoom Doc via the workspace/docs write tools create_new_file_with_markdown / create_file_with_content (both names occur — the workspace and docs sub-servers diverge; suffix-match both), or into a Zoom Whiteboard via the Whiteboard sub-server's content-bearing create tools (create_a_whiteboard_by_script, create_a_whiteboard_for_meeting_summary, and the other create_a_whiteboard_for_* verbs). This chat-scoped policy matches none of them. Zoom Docs are the app's documented data-landing/exfil channel, and script-driven whiteboards are a second text-write route. Compose companion block-secrets policies that guard those doc- and whiteboard-write tools' content arguments to close these routes; this policy alone does not. The tests.yaml red-team cases assert each of these residuals (a secret sails through create_new_file_with_markdown, create_file_with_content, and create_a_whiteboard_by_script).
  • No identity-based exemptions. All callers are subject to the same check. If you need an InfoSec break-glass user that can post anything, gate it with input.subject.claims.groups as a separate allow if branch.
  • Unverified sub-server naming. The landscape note records that Team Chat's endpoint naming diverges (/mcp/team_chat/ vs /mcp/chat/); the tool names themselves (zoom_chat_message_send/zoom_chat_message_update) are verified from Zoom's team-chat child skill, but confirm the gateway-sent prefix with the dump-input technique.

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 zoom.ingress.block_secrets_chat

# Deny-by-default: only the explicit allow rules below permit the request.
default allow := false

# Patterns that look like secrets in plain text. Anchored to common shapes
# (key=value pairs and provider-specific prefixes) to limit false positives.
# This is the conservative set reused from the Slack block-secrets model policy,
# plus a Bearer-token shape.
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+`,
    # HTTP Bearer authorization tokens
    `(?i)bearer\s+[A-Za-z0-9._~+/-]{20,}=*`,
    # AWS access key IDs
    `AKIA[0-9A-Z]{16}`,
    # AWS secret access keys (40-char base64-ish)
    `(?i)aws(.{0,20})?(secret|access)?.{0,20}[\s:=]+[A-Za-z0-9/+=]{40}`,
    # GitHub fine-grained / classic personal access tokens
    `ghp_[A-Za-z0-9]{36}`,
    `github_pat_[A-Za-z0-9_]{82}`,
    # Slack tokens (xoxb-, xoxp-, xoxa-, xoxr-)
    `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-----`,
]

# Zoom Team Chat send/update tools we want to inspect. The Team Chat sub-server
# prefixes every tool with `zoom_chat_`, and the gateway further prefixes the
# configured MCP server name, so we match on the suffix to stay portable across
# naming conventions. Verify the exact tool name on your gateway with the
# dump-input debug technique before relying on this in production.
is_chat_send_tool if {
    name := lower(input.resource.name)
    endswith(name, "zoom_chat_message_send")
}

is_chat_send_tool if {
    name := lower(input.resource.name)
    endswith(name, "zoom_chat_message_update")
}

# Allow any tool that isn't a Team Chat send/update call.
allow if {
    not is_chat_send_tool
}

# Allow Team Chat send/update calls only when no secret pattern matches the body.
allow if {
    is_chat_send_tool
    not message_contains_secret
}

# Pull the message body from the Team Chat `message_content` argument. Nested
# object.get so a call missing `args` entirely yields "" (nothing to leak) rather
# than silently failing the rule body.
message_content := object.get(object.get(input.payload, "args", {}), "message_content", "")

# Detect a secret pattern in the message body.
message_contains_secret if {
    some pattern in secret_patterns
    regex.match(pattern, message_content)
}

reasons contains "This Zoom Team Chat message looks like it contains a secret (API key, password, bearer token, or private key). Store the credential in your secret manager and share a reference instead of the raw value. Contact your InfoSec team if this was a false positive." if {
    is_chat_send_tool
    message_contains_secret
}

reason := joined if {
    count(reasons) > 0
    reason_list := sort([r | some r in reasons])
    joined := concat("; ", reason_list)
}

Canonical source: policy.md on GitHub · raw · raw on this site (.md)

Used in these guides