Secret & PII Scanner for Confluence and Jira

Secret & PII Scanner for Confluence and Jira

Find leaked credentials and personal data in Confluence pages and Jira issues before someone else does, or before an AI assistant starts quoting them. Everything runs on Atlassian's Forge platform. The app has no external endpoints and sends no data outside your Atlassian site.

What it finds

DetectorSeverityHow it decidesExample of what admins see
Private key (RSA, EC, DSA, OpenSSH, PGP, PKCS#8)critical (high if encrypted)-----BEGIN … PRIVATE KEY----- and at least 40 base64 characters within the next 400 characters (a bare header in docs is ignored)-----BEGIN RSA PRIVATE KEY----- …
AWS access key IDhighAKIA/ASIA/ABIA/ACCA/A3T… + 16 base32 characters; AWS's documented …EXAMPLE keys ignoredAKIA…TP
AWS secret access keycriticalaws_secret_access_key (and variants) assigned a 40-character value with entropy ≥ 3.5 bits/char…2xN4
GitHub tokencriticalghp_, gho_, ghu_, ghs_, ghr_ + 36 characters; github_pat_ + 82 charactersghp_…3z5A
GitLab tokencriticalglpat-, gldt-, glrt-, glsoat-, glft-, glcbt- + 20–64 characters; glptt- + 40 hexglpat-…mL
Slack tokenhighxoxb-/xoxp-/xoxa-/xoxo-/xoxs-/xoxr- + numeric id + secretxoxb-…UvWx
Slack incoming webhook URLhighhttps://hooks.slack.com/services/T…/B…/… (also workflows/triggers)https://hooks.slack.com/…uVwX
Stripe secret / restricted keycritical (low in test mode)sk_live_, rk_live_, sk_test_, rk_test_ + 16+ characters. Publishable pk_ keys are not reportedsk_live_…OpQr
Password in a URLhighscheme://user:password@host (for example postgres://admin:…@db.acme.io); the user part may contain @ and the password may contain unencoded @ or / (it runs to the last @ before the host name, unless an earlier @ is followed by a host name with a dot or a port and then /, ?, #, , or ;); several URLs joined by , or ; are each reported, also when an earlier URL in the list is skipped (a placeholder, a short or default password, a host:port/path or user-only URL); placeholders such as user:password@ and host:port/path URLs ignored. Reported instead of an email address********
Authorization headerhighBearer + 16+ token characters with a digit and entropy ≥ 3.5, or Basic + base64 that decodes to user:password…e7f8
JSON Web Tokenmediumthree base64url parts; header and payload must decode to JSON and the header must name an algorithm other than noneeyJ…sw5c
API key / token in an assignmenthighkeyword (api_key, client_secret, access_token, secret, token, …; also glued to the name before it in camelCase, slackToken, sendgridApiKey, in PascalCase after an acronym, AWSSecretKey, JWTSecret, or in upper case, GITLABTOKEN; a token right after next, page, skip, continuation, sync, cursor, resume or max is a pagination cursor and is ignored, as in NextToken, nextPageToken, next_page_token) + =, : (also full-width :), => or is + a value of 16+ characters with a digit, ≥ 2 character classes and entropy ≥ 3.5 bits/char; placeholders (${VAR}, YOUR_API_KEY, <token>, xxxx, process.env…) ignored…3sA1
Password in an assignmentmediumpassword/passwd/passphrase/pwd/pass/pw (also glued in camelCase, dbPassword, adminPass, in PascalCase after an acronym, DBPassword, SMTPPassword, or in upper case, PGPASSWORD, REDISPASS; a glued short Pass/PASS needs a value with a digit or a symbol and is never the end of compass, bypass, surpass, trespass, overpass or underpass) + operator + 8–128 characters, ≥ 2 character classes, entropy ≥ 2.5 (a quoted value may contain spaces, ; and ,; an unquoted one ends at the next ;Key= of a connection string); also mysql -p<password>; placeholders and common defaults ignored********
Payment card numberhigh13–19 digits, Luhn-valid, a real card brand range and length, consistent grouping (4-4-4-4, 4-6-5, …); providers' published test numbers ignored. Not reported when another group of 4+ digits comes directly before it, or directly after it unless that group is an expiry, which is a date (2027/12), or MMYY / 20YY followed by a CVV (3 digits; 4 only for 15-digit Amex; no further digit group after it) or with a card keyword (card, cc, visa, exp, cvv, …) within 40 characters before the number** ** 6467
IBANmediumcountry code with the exact IBAN length for that country, mod-97 checkDE ** 3000
Phone numberlow+ and 8–15 digits, or North American (415) 555-0132 / 415-555-0132+ ** 58
Email addresslowstandard address shape; example.com, .test, .invalid, localhost and image names like logo@2x.png ignored, and so are logins inside a URL authority (ssh://git@github.com:22/…; a , or ; ends the URL, so https://acme.io,bob@acme.io is still an address) and scp-style git remotes (git@github.com:org/repo.git)j*@a*.io

When two detectors match the same text, the more specific one wins (a GitHub token after token = is reported once, as a GitHub token).

The full value is never stored

A finding keeps the detector, severity, field, line, a masked value and up to 24 characters of text before it, so you can see password: or Card . That text gets scrubbed too. Other matches, groups of 4+ digits and credential-like runs become … or •••. A credential-like run means 10+ token characters with a digit or mixed case, or 8+ with both a digit and a letter, such as Tr0ub4dor (Jira issue keys, ISO dates and camelCase or PascalCase key names ending in a keyword, such as adminPassword or SMTPPassword, stay readable).

Values after a credential keyword are replaced as well, and so are values after a glued one (adminPassword: x → adminPassword: …, JWTSecret: x → JWTSecret: …, PGPASSWORD=x → PGPASSWORD=…). With : or = after the keyword, everything up to the end of the line or the next , / ; / . is replaced, so multi-word passphrases are hidden whole (password: my dog rex, mail → password: …, mail). Without an operator, the next word goes only when it has a digit or a symbol (PIN 739 → PIN …, PIN 12 34 → PIN …).

A secret in a page title is masked in the stored title, and titles get the same keyword and credential-run filter. But the filter isn't a guarantee. An undetected credential made only of lower-case letters and written with no keyword (for example login admin / sunflower), or one after an all-lower-case run-together keyword (dbpassword: sunflower), can still remain in a stored context or title.

Finding ids are hashes of the location plus a keyed fingerprint of the value. The fingerprint is HMAC-SHA256 with a random 256-bit secret that the app creates once per installation and keeps in its own Forge storage. So two different secrets with the same mask (every password masks to ********) get different ids, and nobody can match an id to a value without that installation's secret.

How scanning works

Automatic scans run when a page or blog post is created or edited, and when an issue is created, edited, commented on or deleted. You can turn them off, or skip spaces and projects, in Settings.

An administrator can also scan one space (all current pages and blog posts) or one project (all issues) on demand. The scan runs in the background in steps of at most 10 requests, with 25 pages or 50 issues per request, 100 ms between requests and a 2-second pause between steps. You can cancel it. When it finishes, findings of content that no longer exists in that space or project are removed.

On HTTP 429 or 503 the app waits as long as Retry-After asks (seconds or HTTP date, or a 1, 2, 4 s backoff without it). Only waits of up to 10 seconds happen inline, with at most 3 retries. For a 429 with a longer wait, or when the retries run out, the scan step stops and gets re-queued after Retry-After, at least 60 seconds and at most 15 minutes later. Fifteen minutes is the Forge queue's longest delay, so if Retry-After asks for more, the next step is rate-limited again and waits again. A 503 that doesn't clear counts as an error, which means a 30-second pause, and the scan stops after 5 errors in a row.

Page and issue events retry too. When one hits HTTP 429 or 5xx, the handler returns a Forge InvocationError so the platform delivers the event again after at least 60 seconds (longer if Retry-After asks, at most 15 minutes). Forge retries an event at most 4 times. After that, the next edit or a manual scan picks up the change. The retry request follows Atlassian's product-event docs but hasn't been exercised on a live site yet (NOTES.md).

If the space-key lookup for an event fails for any other reason, the page is still scanned and stored under its previous space key (or space <id>). A failed lookup never deletes findings.

In Jira the app reads the summary, description, environment and up to 100 comments per issue.

Admin page

In Confluence it's under Settings → Secret & PII Scanner. In Jira, look under Settings → Apps → Secret & PII Scanner.

The Findings tab filters by severity, status (open / false positive / all), category (secrets / PII), detector and space or project key. It shows 25 rows per page, most severe first.

Marking a finding as a false positive hides it, and the same value in the same field of the same page or issue stays hidden on later rescans. A different value is a new finding, even if it masks the same way. When the finding disappears (the value is removed, or the content is cleaned or deleted), the mark goes with it, so pasting the value again shows it again. Reopen undoes a mark.

Export CSV opens a dialog with the CSV text to copy, since UI Kit has no file download. One export holds up to 1,000 rows, so use filters for more. Cells that start with =, +, -, @, tab or CR get a leading ' so spreadsheets don't run them as formulas.

The Scan tab starts or cancels a space or project scan and shows progress. Settings covers auto-scan on/off, which detectors run, and the spaces or projects to skip in auto-scan. Keys are compared ignoring case, and Jira keys are saved in upper case.

Who can open it

On every request the app checks, as you, that you're a Confluence administrator (for the Confluence page, GET /wiki/rest/api/user/current?expand=operations, operation administer on the application) or hold the Administer Jira global permission (for the Jira page, GET /rest/api/3/mypermissions?permissions=ADMINISTER). Anyone else gets "Only … administrators can use this page". If the check itself fails, the request is refused.

The first time you open the page, Atlassian may ask you to allow the app to act as you. These checks need that.

Findings in content you can't open

The app checks, as you, which of the stored pages or issues you can open. Findings in content you can't open (restricted or deleted) are left out of the list, the totals, the severity counts and the CSV export, and you can't change their status. A notice says that some findings were left out, without saying how many. If the check fails, no findings are shown.

Limits

LimitValue
Text scanned per field2,000,000 characters (the rest is skipped; the record is flagged)
Body read per page5,000,000 characters of storage format
Findings per detector per field25 stored; more are counted
Findings per page/issue100 stored (most severe first); more are counted
Same value in the same fieldgrouped into one row with an occurrence count
Comments per issue100
Documents read per admin page load3,000 with findings (a notice says when the list is cut); for each load the app also checks which of them you can open (250 pages or 100 issues per request, 4 requests at a time)
CSV export1,000 rows
Excluded spaces/projects100
One running scan per producta scan with no progress for 30 minutes counts as stalled and can be replaced

Performance and ReDoS safety

All patterns use bounded repetition and look-behind anchors, so each run of characters is tried from its start only. test/redos.test.js runs every detector against 59 adversarial 1 MB inputs (repeated prefixes like AKIA, ghp_, eyJ, -----BEGIN …, password=, aPassword=, APASSWORD=, postgres://a:, ab://, comma-joined URL credentials, URL lists whose first URL is skipped, ABPassword=, nextPageToken: , BYPASS=, Bearer , mysql -u , card + expiry groups, digits, a@, very long local parts…) and checks that 4× more input takes well under 16× the time.

On the build machine (2026-11-05, after fix round 4) the slowest detector/input pair took 64 ms per 1 MB (url-credentials on repeated ab://). The full detector stays under about 0.25 s per 2 MB field. The tests allow 1.5 s per pair so they don't flake on slow CI.

Privacy and security

See PRIVACY.md. Scopes are read:confluence-content.summary (needed by the Confluence content events), read:page:confluence, read:blogpost:confluence, read:space:confluence, read:confluence-user (the Confluence administrator check), read:jira-work and storage:app. There are no write scopes on Confluence or Jira, and storage:app writes only to the app's own storage. No external fetch, no remotes.

Known limitations

What isn't scanned

Detection limits

Other notes

One app covers both products. Confluence is the required product and Jira is optional (app.compatibility in the manifest). How Marketplace prices such an app (which product's user count is billed) isn't verified yet, see LISTING.md.

Line numbers count lines of the extracted text, which roughly follows paragraphs and table cells.

Support Zovo

Zovo builds and maintains this app. If it saves you time, you can support the work on GitHub Sponsors. Sponsorship is optional, and the tool works the same without it.

Zovo is made by theluckystrike on GitHub.

More tools by Zovo

Support this work

If this app saves you time, you can support the work on GitHub Sponsors. Monthly tiers start at $5, and one-time tiers start at $15. Sponsorship is optional, and the tool works the same without it.

Sponsor on GitHubZovo on GitHub