JIRA: Redact Secrets from Ticket Writes

Engineers paste logs, stack traces, and config snippets into tickets all the time, and an agent filing a bug from an incident does the same.

On this page
  1. What it does
  2. What gets masked
  3. Compliance alignment
  4. Why redact instead of block
  5. Tool name matching
  6. Examples
  7. Masked
  8. Composition
  9. Known limitations
  10. Policy source (Rego)
  11. Related policies
Direction
ingress (tool_pre_invoke)
Default
allow (transform-only, never denies)
Rego package
jira.ingress.redact_secrets_writes
App
Jira
Bundle
Engineering Stack Jira
Published
Minimum gateway
1.0.0b24
Schema version
1.0.0
Checksum
sha256:ed64fd38ed9f98c59f723be603a15f8e9c301ab0326db6723f3477d3d1af748b

What it does

Engineers paste logs, stack traces, and config snippets into tickets all the time, and an agent filing a bug from an incident does the same. This policy masks credentials in everything the agent writes to Jira before it's saved, so a token never lands in a ticket that half the company can read. The ticket is still created; only the secret is replaced.

It covers creating and editing issues, adding or editing comments, worklogs, and the fields and comments sent with a transition. Every string in the request is scanned, including the description, summary, additional_fields, and the text inside Atlassian Document Format (ADF) bodies. IDs and keys pass through unchanged.

What gets masked

Each match is replaced with [REDACTED-SECRET]: password= / token: / api_key= style pairs, AWS access keys and secret keys, GitHub and GitLab tokens, Slack tokens, Stripe live keys, Google API keys, OpenAI-style keys, JWTs, Bearer tokens, PEM private keys, and the password part of connection strings (postgres://app:[REDACTED-SECRET]@db). Email addresses and names are left alone, so assignees, reporters, and mentions still work.

Quoted values and credential fields. key="value" and key: 'value' pairs are masked the same as unquoted ones, including JSON-style "password": "..." inside a log line or comment. In JSON arguments the agent sends, a string under a credential-named field (password, apiKey, client_secret, accessToken, db.password, …) is masked whatever its value looks like. Pagination cursors such as nextPageToken are left alone.

Compliance alignment

  • SOC 2 CC6.1; CC6.7 — keeps credentials from being copied into a widely shared system.

Why redact instead of block

A blocked ticket write stops the agent mid-task and usually gets retried by hand, secret included. Masking the secret lets the ticket through and removes the one thing that shouldn't be in it.

Tool name matching

Tools are matched by suffix, so the policy works whatever name your gateway gives the Atlassian server. Hosted server (Rovo): createJiraIssue, editJiraIssue, addCommentToJiraIssue, addWorklogToJiraIssue, transitionJiraIssue. Community mcp-atlassian: jira_create_issue, jira_update_issue, jira_add_comment, jira_add_worklog, jira_transition_issue.

Examples

Masked

{
  "input": {
    "action": "tool_pre_invoke",
    "resource": { "name": "atlassian-createJiraIssue", "type": "tool" },
    "payload": { "args": { "cloudId": "acme", "projectKey": "PLAT", "issueTypeName": "Bug",
      "summary": "Checkout 500s", "description": "Repro with token=abc123def456 against prod" } }
  }
}

transform.transformed_payload.description = "Repro with [REDACTED-SECRET] against prod".

Composition

Pair with jira/redact-secrets-egress, which masks secrets already sitting in tickets when the agent reads them.

Known limitations

  • Known shapes only. Secrets that don't match a listed shape pass through.
  • Confluence writes are not covered. Use confluence/block-secrets for pages and comments.

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 jira.ingress.redact_secrets_writes

# Transform-only policy — never denies. Masks credentials in the text an agent
# is about to write, keeping the rest of the arguments as they are.
default allow := true

secret_marker := "[REDACTED-SECRET]"

# Credential shapes, applied in order. JWT and Bearer come before the generic
# key=value pair so a token is masked whole. The generic pair takes a quoted
# value ("..." or '...', on one line) or an unquoted one that stops at quotes,
# commas, and semicolons so it never eats into neighbouring text. The key may be
# quoted and carry a prefix (`"db_password": "x"`, `client.secret='x'`).
secret_patterns := [
    # JSON Web Tokens
    `eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`,
    # Bearer tokens
    `(?i)bearer\s+[A-Za-z0-9._~+/-]{16,}=*`,
    # Generic password / token / api_key / secret in `key: value` or `key=value` form,
    # quoted or not
    `(?i)["']?[A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|secret[_-]?key|access[_-]?key|client[_-]?secret)["']?\s*[:=]\s*(?:"[^"\r\n]+"|'[^'\r\n]+'|[^\s"'<>,;]+)`,
    # AWS access key IDs (long-term and temporary)
    `(?:AKIA|ASIA)[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 tokens (personal, OAuth, user, server, refresh) and fine-grained PATs
    `gh[pousr]_[A-Za-z0-9]{36}`,
    `github_pat_[A-Za-z0-9_]{82}`,
    # GitLab personal access tokens
    `glpat-[A-Za-z0-9_-]{20}`,
    # Slack tokens
    `xox[baprs]-[A-Za-z0-9-]{10,}`,
    # Stripe live secret and restricted keys
    `(?:sk|rk)_live_[A-Za-z0-9]{24,}`,
    # Google API keys
    `AIza[0-9A-Za-z\-_]{35}`,
    # OpenAI-style API keys
    `sk-[A-Za-z0-9_-]{20,}`,
    # PEM private key blocks, then any stray header
    `-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----`,
    `-----BEGIN [A-Z ]*PRIVATE KEY-----`,
]

# Passwords embedded in connection strings (postgres://user:pass@host): keep the
# scheme and user, mask the password.
conn_string_pattern := `(?i)\b([a-z][a-z0-9+.-]*://[^\s:/@"']+:)[^\s@/"']+@`

# --- Redaction of one string (Rego has no recursion, so the fold is a chain) ---

_redact(t) := out if {
    r0 := regex.replace(t, secret_patterns[0], secret_marker)
    r1 := regex.replace(r0, secret_patterns[1], secret_marker)
    r2 := regex.replace(r1, secret_patterns[2], secret_marker)
    r3 := regex.replace(r2, secret_patterns[3], secret_marker)
    r4 := regex.replace(r3, secret_patterns[4], secret_marker)
    r5 := regex.replace(r4, secret_patterns[5], secret_marker)
    r6 := regex.replace(r5, secret_patterns[6], secret_marker)
    r7 := regex.replace(r6, secret_patterns[7], secret_marker)
    r8 := regex.replace(r7, secret_patterns[8], secret_marker)
    r9 := regex.replace(r8, secret_patterns[9], secret_marker)
    r10 := regex.replace(r9, secret_patterns[10], secret_marker)
    r11 := regex.replace(r10, secret_patterns[11], secret_marker)
    r12 := regex.replace(r11, secret_patterns[12], secret_marker)
    r13 := regex.replace(r12, secret_patterns[13], secret_marker)
    out := regex.replace(r13, conn_string_pattern, concat("", ["${1}", secret_marker, "@"]))
}

# --- Redaction of every string inside a JSON value, keeping its structure ---

_is_container(x) if is_object(x)

_is_container(x) if is_array(x)

_escape_segment(seg) := replace(replace(sprintf("%v", [seg]), "~", "~0"), "/", "~1")

_pointer(path) := concat("", [concat("", ["/", _escape_segment(seg)]) | some seg in path])

# Field names that hold credentials. Compared lowercased with `_`, `-` and `.`
# removed, so apiKey, api_key, client-secret, db.password and accessToken all
# match. Pagination and sync cursors (nextPageToken, cursor_token) are not
# credentials and are left alone.
_credential_field_suffixes := {
    "password", "passwd", "passphrase", "secret", "token", "apikey",
    "accesskey", "secretkey", "privatekey", "credential", "credentials",
}

_cursor_words := {"page", "cursor", "next", "sync", "continuation"}

_credential_field(k) if {
    is_string(k)
    n := replace(replace(replace(lower(k), "_", ""), "-", ""), ".", "")
    some s in _credential_field_suffixes
    endswith(n, s)
    not _is_cursor_field(n)
}

_is_cursor_field(n) if {
    some w in _cursor_words
    contains(n, w)
}

# A non-empty string under a credential-named field is masked whole, whatever
# its value looks like. Any other string goes through the shape patterns.
_redact_at(path, v) := secret_marker if {
    count(path) > 0
    _credential_field(path[count(path) - 1])
    v != ""
} else := _redact(v)

_patches_for(doc) := [{"op": "replace", "path": _pointer(path), "value": r} |
    walk(doc, [path, v])
    is_string(v)
    r := _redact_at(path, v)
    r != v
]

# --- Scope ---

_tool := lower(object.get(input.resource, "name", ""))

# Hosted Atlassian MCP server (Rovo) names, plus the community mcp-atlassian names.
_write_suffixes := {
    "createjiraissue", "editjiraissue", "addcommenttojiraissue",
    "addworklogtojiraissue", "transitionjiraissue",
    "jira_create_issue", "jira_update_issue", "jira_add_comment",
    "jira_add_worklog", "jira_transition_issue",
}

_is_write_tool if {
    some s in _write_suffixes
    endswith(_tool, s)
}

_args := object.get(object.get(input, "payload", {}), "args", {})

_patches := _patches_for(_args) if is_object(_args)

transform := {"transformed_payload": json.patch(_args, _patches)} if {
    input.action == "tool_pre_invoke"
    _is_write_tool
    count(_patches) > 0
}

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

↑↓ to navigate ↵ to open

Get in touch

Tell us what you need to govern.

A few lines is plenty — what you're deploying, and what you need control over. We read every one.