---
name: "Dropbox: Redact PII, PANs, and Secrets in File Content"
tags:
  - dropbox
  - redact-content
  - redact-pii
  - mask-pan
  - secrets
  - pii
  - dlp
  - egress
  - soc2
  - hipaa
  - gdpr-ccpa
publishedAt: 2026-07-12
description: |
  # dropbox / redact-content-egress

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

  ## What it does

  Scans the responses of the Dropbox file-content read tools and sanitises the
  returned text before it reaches the agent. Dropbox is a generic bucket, so one
  `path` can return PII, PHI, a financial statement, or a credentials file — this
  policy is the primary DLP layer on the MCP read path. It never blocks the read;
  it only rewrites the body:

  | Class | Detection | Result |
  |---|---|---|
  | Payment card (PAN) | 13-19-digit runs and 4×4 / Amex 4-6-5 grouped forms, **Luhn-validated** in Rego | masked to **BIN + last-four** (`4111 1111 1111 1111` → `411111******1111`) |
  | US national ID (SSN / ITIN) | hyphenated `XXX-XX-XXXX` form | `[REDACTED-SSN]` |
  | Email address | standard shape | `[REDACTED-EMAIL]` |
  | US phone number | separator-formatted 3-3-4 | `[REDACTED-PHONE]` |
  | Secret | `AKIA…` access-key IDs, PEM `-----BEGIN … PRIVATE KEY-----` blocks, provider token prefixes (`ghp_`, `github_pat_`, `xox[baprs]-`, `sk_live_`, `AIza…`, `sk-…`) | `[REDACTED-SECRET]` |

  Matches are rewritten in place, so the surrounding document structure, citations,
  and extraction fields stay usable. The policy is transform-only: it never denies
  a call, and responses with no matches (and all non-content tools) pass through
  byte-identical.

  ### Full-PAN carve-out

  Callers whose IdP `groups` claim contains the placeholder group `pci-fullpan`
  (fraud / chargeback staff who genuinely need the complete number) receive the
  **unmasked** PAN. The carve-out is read via `object.get(input.subject, "claims",
  {})`: a missing subject, missing claims, missing `groups`, or a malformed
  `groups` claim all leave the caller **not exempt**, so PAN masking applies — the
  grant fails closed. The carve-out is **PAN-only**: even an exempt caller still
  gets SSNs, emails, phones, and secrets redacted, because there is no role that
  needs raw credentials or Social Security numbers in agent context.

  ## Compliance alignment

  - **PCI DSS 3.4.1** — supports masking of PAN on display: the agent channel
    shows at most BIN+last4, with full-PAN visibility limited to the defined
    `pci-fullpan` role. **PCI DSS 3.4.2** — supports preventing PAN copy/relocation
    via remote-access technologies: an agent that only ever receives the masked
    form cannot re-post the full PAN into another file, share, or chat. **PCI DSS
    12.10.7** — the gateway's transform/decision audit events give the
    "PAN-where-not-expected" incident process a concrete trigger, since a Dropbox
    document is a classic not-expected location for cardholder data.
  - **SOC 2 CC6.7** — supports restricting the transmission/movement of
    confidential information by masking/redacting direct identifiers in Dropbox
    content as it leaves the gateway toward the agent. **SOC 2 C1.1 / P4.1** —
    supports identifying and protecting confidential information and limiting
    personal-information use to identified purposes on the read path.
  - **HIPAA §164.502(b) / §164.514(d)** — supports minimum-necessary, role-based
    access: identifiers are stripped for everyone; only the `pci-fullpan` role sees
    raw card numbers. **HIPAA §164.514(a)–(b)** — supports de-identification
    practice by stripping Safe-Harbor identifier classes (SSN, email, phone) from
    responses. **HIPAA §164.530(c)** — administrative safeguard on the agent read
    path.
  - **GDPR Art. 5(1)(c)** — data minimisation on agent reads of personal data.
    **GDPR Art. 9** — reduces special-category exposure where identifiers co-occur
    with health/financial content. **CCPA/CPRA §1798.121** — supports limiting the
    use and disclosure of sensitive personal information (SSN, financial account
    numbers) on the agent channel; **§1798.150** — reduces nonredacted-PI breach
    exposure.

  ## Why egress

  The PII, PANs, and secrets already live in Dropbox — there is nothing to block
  at ingress, and denying reads outright would make the documents unusable. 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.

  ## Tool name matching

  Applies on the output path — scoped when either `input.mode == "output"` or
  `input.action == "tool_post_invoke"` holds, so redaction still fires on a gateway
  build that populates only one of the two (keying on `mode` alone would fail open
  if it were unset). Tools are matched case-insensitively **by suffix**, so it
  works regardless of the MCP server-name prefix the gateway adds
  (`dropbox-mcp-…`, `dbx-…`, 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.

  Content-read tools matched (from the Dropbox landscape research):

  - **`*GetFileContent`** — official remote server (mcp.dropbox.com; PascalCase),
    extracts text from PDF/Word/text up to 5 MB. The main egress surface.
  - **`*get_file_content`**, **`*download_file`** — `amgadabdelhafez/dbx-mcp-server`
    (community, snake_case).
  - **`*dropbox_download`** — `ngs/dropbox-mcp-server` (community, `dropbox_` prefix).

  Metadata / search tools (`GetFileMetadata`, `ListFolder`, `Search`) are
  deliberately out of scope — this policy sanitises returned **file content**, not
  filenames or listings. The externally-visible URL minter `DownloadLink` (official)
  returns a *URL*, not content, so it is intentionally **not** matched (it never
  ends in a content suffix) — pair it with `apps/dropbox/guard-share-links-external`
  on ingress.

  Tool names are verified against Dropbox's help docs and the community READMEs,
  but the official server's **argument/response JSON schemas are unverified** (the
  landscape note flags that Dropbox does not publish them, and the help page lists
  "21 tools" while enumerating 23). Verify the exact tool names and the response
  content-block shape your gateway emits with the dump-input debug technique before
  relying on this in production, and add suffixes for any other content-returning
  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 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).

  ## Examples

  ### Transformed (content tool, non-exempt caller)

  ```jsonc
  {
    "input": {
      "action": "tool_post_invoke",
      "mode": "output",
      "resource": { "name": "dropbox-mcp-GetFileContent", "type": "tool" },
      "subject": { "sub": "auth0|u1", "claims": { "groups": ["support"] } },
      "payload": {
        "name": "dropbox-mcp-GetFileContent",
        "text": ["SSN 123-45-6789, card 4111 1111 1111 1111, key AKIA1234567890ABCDEF"]
      }
    }
  }
  ```

  `allow = true`, with `transform.transformed_payload.text` =
  `["SSN [REDACTED-SSN], card 411111******1111, key [REDACTED-SECRET]"]`.

  ### Transformed (exempt caller — PAN kept, SSN still redacted)

  ```jsonc
  {
    "input": {
      "action": "tool_post_invoke",
      "mode": "output",
      "resource": { "name": "dropbox-mcp-GetFileContent", "type": "tool" },
      "subject": { "sub": "auth0|u2", "claims": { "groups": ["pci-fullpan"] } },
      "payload": {
        "name": "dropbox-mcp-GetFileContent",
        "text": ["SSN 123-45-6789, card 4111 1111 1111 1111"]
      }
    }
  }
  ```

  `allow = true`, `transform.transformed_payload.text` =
  `["SSN [REDACTED-SSN], card 4111 1111 1111 1111"]` — the `pci-fullpan` group
  keeps the raw card number but the SSN is still redacted.

  ### Passed through (no sensitive data / out-of-scope tool)

  A content block with no PII/PAN/secret produces no transform. A metadata or
  listing tool (`GetFileMetadata`, `ListFolder`) is out of scope and passes through
  byte-identical even when its output contains a match.

  ## Composition

  Single-purpose transform policy (`default allow := true`); it composes cleanly
  with deny policies on the same egress pipeline. Recommended companions in
  `apps/dropbox`:

  - **fence-sensitive-paths** (ingress) — keeps agents out of protected folder
    trees entirely, covering formats this policy cannot text-scan.
  - **guard-share-links-external** (ingress) — so redacted-on-read content isn't
    simply shared out through `CreateSharedLink` / `DownloadLink` /
    `CreateFileRequest` instead.

  ## Known limitations

  - **Conservative regex over extracted text — high-signal, not complete DLP.**
    Detection runs only over the text the server extracts. Custom-format
    identifiers, values inside binary or office formats the server does not
    text-extract, obfuscated/spelled-out/base64-encoded values, and anything split
    across content blocks are **not** caught. Treat this as a high-signal layer,
    not a guarantee. The PAN, SSN, email, and phone patterns are `\b`-anchored, so a
    sensitive value glued **directly** to an adjacent word character with no
    separating space/punctuation (e.g. an email whose TLD abuts a card number,
    `jane@acme.com4111111111111111`) can defeat the word boundary and pass through
    — a contrived shape, but a real edge of boundary-anchored matching.
  - **National-ID coverage is US-shaped only.** The SSN pattern matches the
    hyphenated US SSN/ITIN form (`XXX-XX-XXXX`). Bare 9-digit runs are left alone
    (they collide with Dropbox file IDs and countless document numbers), and
    non-US national-ID formats (which are country-specific) are not matched — add
    their shapes to the Rego if your corpus contains them.
  - **Email and phone are redacted stand-alone.** Every email address and every
    separator-formatted US phone number in a matched response is redacted, so
    document footers and "contact us" lines lose their contact details. If that is
    too aggressive for your corpus, pair-gate them (see `apps/box/redact-pii-egress`
    for the co-occurrence heuristic) or narrow the patterns.
  - **Luhn-valid non-card numbers are masked too.** The Luhn check eliminates most
    IDs and timestamps, but some checksummed non-card numbers (certain IMEIs, etc.)
    are Luhn-valid and will be masked; the masked form keeps BIN+last4, so such
    false positives usually stay recognisable. A card split across content blocks
    (no single block with 13+ contiguous card digits) is not masked.
  - **Secret detection is prefix/shape-based.** Only `AKIA…`, PEM private-key
    **blocks** (BEGIN…END in one content block; a header without its END marker, or
    a key split across blocks, is missed), and the listed provider token prefixes
    are caught. Generic `key: value` credential pairs, custom-format or short-lived
    tokens, and any provider not in the list are not matched — extend
    `secret_patterns` for your environment. `sk-[A-Za-z0-9]{20,}` (OpenAI) is a
    broad shape and can over-match unrelated `sk-`-prefixed strings.
  - **Structured (non-string) content blocks and non-array `text` are not scanned
    — fail-open.** The policy rewrites only string entries of `input.payload.text`,
    and only when `text` is a JSON array. A value carried inside a content block
    delivered as a JSON *object* (a typed `{"type":"text","text":"…"}` block), or a
    `payload.text` delivered as a bare string, passes through unredacted. Serialized
    JSON *inside* a string block **is** scanned. Confirm your gateway/server
    delivers string blocks with the dump-input technique.
  - **Group names are placeholders — replace `pci-fullpan` with your IdP's group
    name at import time.** The carve-out honours a `groups` claim shaped as an
    array of strings (a single bare string is also handled). Any other shape — a
    missing subject/claims/`groups`, an object/map (e.g. `{"role": "pci-fullpan"}`),
    a number, null, or nested/non-string array elements — fails closed → PAN masked.
    If your IdP emits roles under a namespaced claim, point `caller_groups` at the
    array before matching.
  - **Egress `transformed_payload` replaces the response payload wholesale.**
    Verify the rewrite against your gateway version with the dump-input technique
    before production, and mind attachment order if other egress transforms run on
    the same pipeline.
  - **MCP path only.** The raw content still exists in Dropbox and in Dropbox's
    own web/desktop/API surfaces; this policy controls only what the *agent* sees
    over MCP.

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

```rego
package dropbox.egress.redact_content

# Transform-only egress policy: masks Luhn-valid PANs to BIN+last4 and redacts
# US SSNs/ITINs, emails, phones, and secret-shaped strings in the responses of
# Dropbox file-content read tools before they reach the agent. Never denies.
# The PAN mask is skipped for callers in the placeholder `pci-fullpan` group;
# SSN/email/phone/secret redaction always applies.
default allow := true

# -----------------------------------------------------------------------------
# Scope: Dropbox tools whose responses carry file-derived content. Suffix
# matching keeps the policy portable across gateway server-name prefixes and
# covers the official remote server (PascalCase) plus the two community servers.
# Metadata/listing/search tools and the URL-minting DownloadLink are NOT matched.
# -----------------------------------------------------------------------------

content_tool_suffixes := {
    # Official remote server (mcp.dropbox.com) — extracts text from PDF/Word/text.
    "getfilecontent",
    # amgadabdelhafez/dbx-mcp-server (community, snake_case).
    "get_file_content",
    "download_file",
    # ngs/dropbox-mcp-server (community, dropbox_ prefix).
    "dropbox_download",
}

# Egress scope: match the post-invoke/output path on either mode or action. If we
# keyed on input.mode alone and a gateway build left it unset, is_content_tool
# would silently fail and redaction would no-op (fail open, leaking content).
# Ingress (tool_pre_invoke / mode "input") satisfies neither branch.
is_egress if { input.mode == "output" }

is_egress if { input.action == "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 content-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(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_content_tool if {
    is_egress
    some suffix in content_tool_suffixes
    some n in candidate_names
    endswith(n, suffix)
}

# -----------------------------------------------------------------------------
# Full-PAN carve-out — placeholder IdP group whose members receive the unmasked
# PAN. Read via object.get(input.subject, "claims", {}); a missing
# subject/claims/groups or a malformed groups claim leaves this rule undefined,
# so PAN masking applies (fail closed). Replace "pci-fullpan" at import time.
# The is_array guard is load-bearing: `some g in caller_groups` over an OBJECT
# iterates its values, so an object-shaped claim like {"role":"pci-fullpan"}
# would else wrongly exempt the caller.
# -----------------------------------------------------------------------------

exempt_group := "pci-fullpan"

caller_groups := object.get(object.get(input.subject, "claims", {}), "groups", [])

caller_may_view_full_pan if {
    is_array(caller_groups)
    some g in caller_groups
    is_string(g)
    lower(g) == exempt_group
}

caller_may_view_full_pan if {
    # Some IdPs emit a single group as a bare string rather than an array.
    is_string(caller_groups)
    lower(caller_groups) == exempt_group
}

# -----------------------------------------------------------------------------
# Detection patterns — anchored and conservative to limit false positives.
# -----------------------------------------------------------------------------

# US SSN / ITIN in the canonical hyphenated form only. Bare 9-digit runs are too
# collision-prone with Dropbox file IDs and document numbers to redact safely.
ssn_pattern := `\b\d{3}-\d{2}-\d{4}\b`

# Email address, standard shape.
email_pattern := `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b`

# Separator-formatted US phone number (3-3-4), optional +1 and area-code parens.
phone_pattern := `(?:\+?1[-. ])?(?:\(\d{3}\)|\b\d{3})[-. ]\d{3}[-. ]\d{4}\b`

# Secret-shaped strings: AWS access-key IDs, PEM private-key blocks, and common
# provider token prefixes. Case-sensitive (the prefixes are case-specific). The
# PEM alternative matches the whole BEGIN…END block, including newlines.
secret_pattern := concat("|", [
    # AWS access key ID.
    `AKIA[0-9A-Z]{16}`,
    # PEM private key block (RSA/EC/DSA/OPENSSH/plain), header through footer.
    `-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----`,
    # GitHub classic and fine-grained personal access tokens.
    `ghp_[A-Za-z0-9]{36}`,
    `github_pat_[A-Za-z0-9_]{82}`,
    # Slack bot/user/app/refresh tokens.
    `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-style secret keys (broad shape — see Known limitations).
    `sk-[A-Za-z0-9]{20,}`,
])

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

pan_pattern := concat("|", [
    # 16-digit PANs grouped 4-4-4-4 with a space, dash, or dot separator.
    `\b\d{4}[-. ]\d{4}[-. ]\d{4}[-. ]\d{4}\b`,
    # 15-digit American Express PANs grouped 4-6-5, constrained to the 34/37 IIN.
    `\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.
    `\b\d{13,19}\b`,
])

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

# Mask a single PAN to BIN+last4: first six digits and last four kept, every
# digit between masked with `*`. Separators are dropped in the masked form.
mask_pan(c) := masked if {
    d := digits_only(c)
    n := count(d)
    masked := concat("", [
        substring(d, 0, 6),
        # RE2 has no repeat builtin, so mask the middle substring char-by-char.
        regex.replace(substring(d, 6, n - 10), `\d`, "*"),
        substring(d, n - 4, 4),
    ])
}

# -----------------------------------------------------------------------------
# Redaction steps — each is total over strings: it returns the input unchanged
# when its class doesn't apply, so the steps chain safely.
# -----------------------------------------------------------------------------

redact_secrets(t) := regex.replace(t, secret_pattern, "[REDACTED-SECRET]")

redact_ssn(t) := regex.replace(t, ssn_pattern, "[REDACTED-SSN]")

redact_email(t) := regex.replace(t, email_pattern, "[REDACTED-EMAIL]")

redact_phone(t) := regex.replace(t, phone_pattern, "[REDACTED-PHONE]")

# Mask PANs unless the caller is in the full-PAN group.
mask_pans(t) := out if {
    replacements := {c: mask_pan(c) | some c in pan_candidates(t)}
    count(replacements) > 0
    out := strings.replace_n(replacements, t)
}

mask_pans(t) := t if { count(pan_candidates(t)) == 0 }

mask_pans_maybe(t) := mask_pans(t) if { not caller_may_view_full_pan }

mask_pans_maybe(t) := t if { caller_may_view_full_pan }

# Order: secrets first (so a token can't be nibbled by later patterns), then
# SSN, email, phone (fixed-token redactions), then Luhn-checked PAN masking.
redact_block(b) := out if {
    is_string(b)
    out := mask_pans_maybe(redact_phone(redact_email(redact_ssn(redact_secrets(b)))))
}

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

# -----------------------------------------------------------------------------
# Transform — emitted only when in scope, `text` is an array, and at least one
# block actually changed. Otherwise the rule is undefined and the aggregator
# skips this policy, returning the response byte-identical. Note the carve-out
# does NOT gate the transform: an exempt caller still gets SSN/secret redaction.
# -----------------------------------------------------------------------------

text_blocks := object.get(input.payload, "text", [])

redacted_blocks := [out |
    some block in text_blocks
    out := redact_block(block)
]

transform := {
    "transformed_payload": object.union(input.payload, {"text": redacted_blocks}),
} if {
    is_content_tool
    is_array(text_blocks)
    redacted_blocks != text_blocks
}
```
