Skip to content

feat(analyzer): add ApiKeyRecognizer for provider-issued credentials - #2203

Open
developer0hye wants to merge 4 commits into
data-privacy-stack:mainfrom
developer0hye:feat/api-key-recognizer
Open

feat(analyzer): add ApiKeyRecognizer for provider-issued credentials#2203
developer0hye wants to merge 4 commits into
data-privacy-stack:mainfrom
developer0hye:feat/api-key-recognizer

Conversation

@developer0hye

@developer0hye developer0hye commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Change description

Adds ApiKeyRecognizer, a generic recognizer for provider-issued credentials under a new API_KEY entity. This targets credentials commonly pasted into logs, support tickets, chat transcripts, notebooks, and configuration snippets, and implements #2202.

The recognizer uses vendor-documented, case-sensitive prefixes or credential names. Length and character classes narrow a match, but are not used alone except for the AWS secret after its documented setting/environment-variable name.

Supported formats

Provider / format Detection rule Primary reference
AWS access key ID Credential prefixes AKIA, ASIA, ABIA, or ACCA plus the identifier body IAM unique ID prefixes
AWS secret access key aws_secret_access_key / AWS_SECRET_ACCESS_KEY, followed by an exactly 40-character secret AWS CLI environment variables, AWS access-key lengths
GitHub token Classic ghp_, gho_, ghu_, and ghr_ token formats; ghs_ GitHub App installation tokens are handled separately so both classic and stateless forms are accepted GitHub token formats, stateless installation-token format
GitHub fine-grained PAT github_pat_ format GitHub authentication token format update
Google API key AIza format Google Cloud API keys
Slack token Bot/user (xoxb-, xoxp-), refresh (xoxe-), rotated access (xoxe.xoxb-, xoxe.xoxp-), app-level (xapp-), and workflow (xwfp-) formats Slack token types, Slack token rotation
Stripe private key Live and sandbox secret/restricted keys (sk_live_, sk_test_, rk_live_, rk_test_) Stripe API keys
Common compact signed JWT Three base64url segments whose header and claims set begin with the common encoded JSON-object marker eyJ RFC 7519 §3

Review follow-up

A source-backed review of the initial implementation found and fixed these correctness gaps:

  • Case sensitivity now survives registry loading. Presidio's default registry uses re.IGNORECASE, and RecognizerListLoader assigns its global flags to each pattern recognizer after construction. Every credential regex therefore embeds a scoped (?-i:...) group instead of relying only on constructor flags. A registry-level regression test covers this behavior. See PatternRecognizer and RecognizerListLoader.
  • AWS secrets require an exact right boundary. The documented 40-character value can no longer match the first 40 characters of a longer base64-like string. The credential name remains a lookbehind, so only the secret value is reported.
  • GitHub App installation tokens cover both generations. The original fixed base62 rule would miss GitHub's documented stateless ghs_ JWT form. The installation-token rule now accepts GitHub's documented [A-Za-z0-9.\-_] character set with a minimum of 36 body characters, so the classic opaque token and the stateless form both match. How that match ends is refined in the second pass below.
  • Slack token rotation is modeled accurately. Refresh tokens (xoxe-) and rotated bot/user access tokens (xoxe.xoxb- / xoxe.xoxp-) are distinct patterns.
  • Stripe sandbox secrets are included. Stripe documents test-mode secret and restricted keys as private server-side credentials, so sk_test_ and rk_test_ are detected; publishable pk_ keys remain excluded.
  • JWT scope is explicit. The recognizer is named “Common compact signed JSON Web Token” rather than implying that it detects every JWT serialization allowed by RFC 7519.

Second review pass

A cross-check of the commit above found one defect and one over-reach. Both are fixed in fix(analyzer): count installation-token length on the reported span.

  • The installation-token minimum was not enforced on the reported span. The 36-character floor lived in a lookahead over [A-Za-z0-9._-], but the consuming part refuses to end on ., so backtracking returned characters the lookahead had already counted. ghs_A followed by 35 dots was reported as a credential — a one-character body. The floor is now written as {35,} plus one final character, so it is counted on what is actually returned. A structured sweep over dotted inputs now bottoms out at exactly 36.
  • The AWS secret alphabet is no longer narrowed. An intermediate revision removed = from the value, reasoning that 30 random bytes encode to 40 characters without padding. That arithmetic holds, but the premise is inferred rather than documented: AWS states the length only, not the generation algorithm, and its own credential-scanning guidance has used [A-Za-z0-9/+=]{40}. Narrowing on an inferred format trades a rare false positive for a false negative on a real secret, which is the worse error for a credential recognizer. The documented alphabet is kept, and tests pin the exact-length right boundary instead — that enforces the documented length without assuming the alphabet.

Span boundaries deliberately left as they are: a Slack token or a JWT signature may legitimately end in - or _, so no terminal-character rule is safe for either. Only . is excluded from the end of an installation token, because it doubles as sentence punctuation — GitHub's recommended regex is written for validation, where no right boundary is needed, whereas Presidio reports a span.

Two test-quality fixes came out of the same pass:

  • The (?-i:) invariant is now asserted structurally: the check walks parenthesis depth to confirm the scope encloses the whole pattern, since a prefix check would accept a group that closes early. The check is itself unit tested.
  • The stateless-token fixture's signature segment grew from 150 to 152 characters. At 150 (150 % 4 == 2) the final character carries only 2 significant bits, so canonical unpadded base64url could end only in A, Q, g or w there — the - / _ fixtures did not represent reachable tokens.

Deliberate exclusions

  • IAM prefixes that identify users, roles, groups, policies, or other non-credential resources are excluded.
  • Stripe publishable (pk_) keys are excluded because Stripe documents them as safe for client-side use.
  • Legacy AWS and Slack prefixes absent from current vendor documentation remain excluded.
  • OpenAI and Anthropic formats remain excluded because no sufficiently precise vendor-published format specification was found.
  • Stripe's documentation mentions an sk_org prefix but does not specify a stable body character set or length, so this PR does not guess a regex for it.
  • RFC 7519 permits forms outside the common compact signed eyJ….eyJ….… subset; broadening this regex without an equally precise textual marker would create avoidable false positives.

Files changed

  • presidio_analyzer/predefined_recognizers/generic/api_key_recognizer.py — recognizer and source-backed patterns
  • predefined_recognizers/__init__.py, generic/__init__.py — exports
  • presidio_analyzer/conf/default_recognizers.yaml — default registration
  • docs/supported_entities.mdAPI_KEY documentation
  • tests/test_api_key_recognizer.py — positive, negative, boundary, span-exactness, and default-registry regression coverage

Per the changelog policy added in #2200, CHANGELOG.md is not changed.

Verification

  • 60 passed — API-key recognizer tests
  • 209 passed — API-key recognizer plus the pattern-recognizer, recognizer-registry and analyzer-engine suites
  • ruff check — passed for the modified Python files
  • ruff format --check — passed for the modified Python files
  • git diff --check — passed

Secret-shaped fixtures are assembled from fragments so GitHub push protection does not mistake synthetic test data for live credentials.

Design choices for maintainers

  • This uses one API_KEY entity, with the provider/format retained in the pattern name and analysis explanation. Per-provider entities would enable different anonymization policies but substantially expand the supported-entity surface.
  • It is enabled in the default recognizer configuration, consistent with other generic recognizers. If introducing a new default output entity is considered too broad, it can be shipped disabled first.

Issue reference

Fixes #2202

Checklist

  • I have reviewed the contribution guidelines
  • I agree to follow this project's Code of Conduct
  • I confirm that I have the right to submit this contribution and that it does not knowingly contain proprietary or confidential code
  • My code includes unit tests
  • Relevant unit tests and lint checks pass locally
  • My PR contains the required documentation updates

developer0hye and others added 2 commits July 28, 2026 14:43
Adds an API_KEY entity covering AWS access key IDs and secret access
keys, GitHub tokens, Google API keys, Slack tokens, Stripe live secret
and restricted keys, and JSON Web Tokens.

Every pattern is anchored on a vendor-documented, case-sensitive prefix
rather than on string length or entropy, so a match is driven by an
unambiguous structural marker. Two details this depends on:

- Matching is case-sensitive. PatternRecognizer defaults to including
  re.IGNORECASE, which would make markers such as AKIA and eyJ fire on
  ordinary lowercase text.
- The AWS secret access key has no structure of its own, so it is
  anchored on the credential name AWS documents (aws_secret_access_key /
  AWS_SECRET_ACCESS_KEY). PatternRecognizer matches with the regex
  module, whose variable-length lookbehind keeps the anchor out of the
  reported span.

The IAM prefix table lists AKIA, ASIA, ABIA and ACCA as credentials;
AIDA, AROA, ANPA and the rest identify users, roles, groups and policies
and are excluded. Stripe publishable keys are excluded because Stripe
documents them as safe to expose.

Patterns were cross-checked against gitleaks and detect-secrets. Two
divergences are deliberate: the AWS access key body follows
detect-secrets' [0-9A-Z] rather than gitleaks' narrower base32 [A-Z2-7],
which risks false negatives; and the GitHub token body excludes the
underscore detect-secrets allows, since the GitHub post specifies base62.

Slack and Stripe test fixtures are assembled from fragments so that the
test file does not itself trip secret scanners.

Supersedes the approach in data-privacy-stack#1339, whose single high-entropy pattern does
not compile on Python 3.11+ and cannot be made precise.

Closes data-privacy-stack#2202

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cross-checking against detect-secrets showed the Slack pattern accepted
xoxa/xoxr/xoxs, which are legacy prefixes absent from Slack's current
token-types documentation, while missing xwfp (workflow tokens), which
is documented.

Narrows the pattern to xoxb/xoxp/xoxe, adds a separate xwfp pattern, and
covers both directions in the tests. Keeps every pattern traceable to a
current vendor document, which is the premise of this recognizer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
developer0hye and others added 2 commits July 28, 2026 15:33
The ghs_ pattern took its 36-character minimum from a lookahead over
[A-Za-z0-9._-], but the consuming part refuses to end on ".", so
backtracking gives those characters back after the lookahead has counted
them. The reported span could fall below the documented minimum: "ghs_A"
followed by 35 dots was returned as a credential, a one-character body.
Spell the minimum as {35,} plus one final character so the length is
counted on what is actually reported. A structured fuzz run over dotted
inputs now bottoms out at exactly 36.

The final character stays [A-Za-z0-9_-] rather than alphanumeric. A
base64url signature may legitimately end with "-" or "_", so pinning it
to alphanumeric would clip a real stateless token by one character. Only
"." is excluded, because it doubles as sentence punctuation: GitHub's
recommended regex is written for validation, where no right boundary is
needed, but Presidio reports a span, and a greedy match otherwise
reported the period ending a sentence as part of the credential.

The AWS secret value keeps the full documented base64 alphabet including
"=". Thirty random bytes would encode to 40 characters with no padding,
which would make "=" impossible in the value, but AWS documents the
length only, not the generation algorithm, and its own credential
scanning guidance has used [A-Za-z0-9/+=]{40}. Narrowing the set on an
inferred format would trade a rare false positive for a false negative
on a real secret, which is the worse error for a credential recognizer.
Tests now pin the exact-length right boundary instead, which enforces the
documented length without assuming the alphabet.

Also:

- Assert the (?-i:) scope encloses each whole pattern instead of only
  checking the prefix, so a group that closes early cannot pass. The
  scope check is itself unit tested.
- Point _case_sensitive at the RecognizerListLoader flag assignment that
  makes it necessary, so it is not removed in isolation later.
- Grow the stateless-token fixture signature to 152 characters. At 150
  (150 % 4 == 2) the final character carries only 2 significant bits, so
  canonical unpadded base64url could not end in "-" or "_" there and the
  new fixtures would not have represented reachable tokens.
- Drop a noqa: E501 that the previous commit's reformatting made dead,
  and the registry-flag implementation detail from the entity table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add a recognizer for provider-issued API keys, access keys and bearer tokens

1 participant