diff --git a/docs/supported_entities.md b/docs/supported_entities.md index eae9f69866..153802278c 100644 --- a/docs/supported_entities.md +++ b/docs/supported_entities.md @@ -12,6 +12,7 @@ For more information, refer to the [adding new recognizers documentation](analyz |Entity Type | Description | Detection Method | | --- | --- | --- | +|API_KEY|A provider-issued API key, access key or bearer token. Covers AWS access key IDs and secret access keys, GitHub tokens (including stateless installation tokens), Google API keys, Slack standard and rotated tokens, Stripe live and sandbox secret or restricted keys, and common compact signed JSON Web Tokens.|Case-sensitive pattern match and context| |CREDIT_CARD |A credit card number is between 12 to 19 digits. |Pattern match and checksum| |CRYPTO|A Crypto wallet number. Currently only Bitcoin address is supported|Pattern match, context and checksum| |DATE_TIME|Absolute or relative dates or periods or times smaller than a day.|Pattern match and context| diff --git a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml index 12791f39aa..0a50f41192 100644 --- a/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml +++ b/presidio-analyzer/presidio_analyzer/conf/default_recognizers.yaml @@ -429,6 +429,9 @@ recognizers: type: predefined enabled: false + - name: ApiKeyRecognizer + type: predefined + - name: CryptoRecognizer type: predefined diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py index ee0e38e265..b5411fc337 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/__init__.py @@ -163,6 +163,7 @@ from .country_specific.us.us_ssn_recognizer import UsSsnRecognizer # Generic recognizers +from .generic.api_key_recognizer import ApiKeyRecognizer from .generic.credit_card_recognizer import CreditCardRecognizer from .generic.crypto_recognizer import CryptoRecognizer from .generic.date_recognizer import DateRecognizer @@ -212,6 +213,7 @@ __all__ = [ "AbaRoutingRecognizer", + "ApiKeyRecognizer", "CaSinRecognizer", "CreditCardRecognizer", "CryptoRecognizer", diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/__init__.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/__init__.py index a8b23a3606..83818fc276 100644 --- a/presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/__init__.py +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/__init__.py @@ -1,5 +1,6 @@ """Generic recognizers package.""" +from .api_key_recognizer import ApiKeyRecognizer from .credit_card_recognizer import CreditCardRecognizer from .crypto_recognizer import CryptoRecognizer from .email_recognizer import EmailRecognizer @@ -11,6 +12,7 @@ from .uuid_recognizer import UuidRecognizer __all__ = [ + "ApiKeyRecognizer", "CreditCardRecognizer", "CryptoRecognizer", "EmailRecognizer", diff --git a/presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/api_key_recognizer.py b/presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/api_key_recognizer.py new file mode 100644 index 0000000000..6a1a798d56 --- /dev/null +++ b/presidio-analyzer/presidio_analyzer/predefined_recognizers/generic/api_key_recognizer.py @@ -0,0 +1,227 @@ +import re +from typing import List, Optional + +from presidio_analyzer import Pattern, PatternRecognizer + +# The AWS secret access key has no distinguishing structure of its own -- it is +# 40 characters of base64, which is indistinguishable from a hash, an id, or a +# slice of an encoded blob. It is therefore anchored on the credential name that +# AWS itself documents: the shared-credentials-file setting +# ``aws_secret_access_key`` and the environment variable +# ``AWS_SECRET_ACCESS_KEY``. +# +# PatternRecognizer matches with the ``regex`` module, which supports +# variable-length lookbehind, so the anchor can be excluded from the reported +# span and only the secret itself is returned. +AWS_SECRET_ANCHOR = ( + r"(?<=(?i:aws_secret_access_key)[\"']?[ \t]{0,8}[:=][ \t]{0,8}[\"']?)" +) + + +def _case_sensitive(regex: str) -> str: + """Keep credential formats case-sensitive under registry-level regex flags. + + ``RecognizerListLoader.get`` assigns the registry's ``global_regex_flags`` + to every ``PatternRecognizer`` *after* construction, and the shipped + registry configuration includes ``re.IGNORECASE``. Constructor flags + therefore cannot keep a vendor prefix case-sensitive; the scoped + ``(?-i:...)`` group can, because it travels with the pattern itself. + + Remove this only together with the flag assignment in + ``recognizer_registry/recognizers_loader_utils.py``. + """ + return rf"(?-i:{regex})" + + +class ApiKeyRecognizer(PatternRecognizer): + """ + Recognize provider-issued API keys, access keys and bearer tokens. + + Every pattern is anchored on a vendor-assigned, case-sensitive prefix, a + documented credential name, or a standards-defined structural marker. + String length and entropy are supporting evidence rather than the primary + signal. + + Note: case sensitivity is encoded inside every default pattern. The + recognizer registry applies its global regex flags after constructing + predefined recognizers, so constructor flags alone cannot preserve + case-sensitive vendor prefixes in the default configuration. + + ref: + - AWS access key ID prefixes (``AKIA``, ``ASIA``): + https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html + - AWS credential names (``aws_secret_access_key`` / + ``AWS_SECRET_ACCESS_KEY``): + https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-envvars.html + - AWS access key lengths: + https://docs.aws.amazon.com/AmazonS3/latest/developerguide/MakingRequests.html + - GitHub token prefixes and base62 body: + https://github.blog/engineering/platform-security/behind-githubs-new-authentication-token-formats/ + - GitHub stateless installation tokens: + https://github.blog/changelog/2026-05-15-github-app-installation-tokens-per-request-override-header/ + - Google API keys: + https://cloud.google.com/docs/authentication/api-keys + - Slack token prefixes: + https://docs.slack.dev/authentication/tokens/ + - Slack token rotation formats: + https://docs.slack.dev/authentication/using-token-rotation/ + - Stripe secret and restricted keys: + https://docs.stripe.com/keys + - JSON Web Token structure (RFC 7519, section 3): + https://datatracker.ietf.org/doc/html/rfc7519#section-3 + + :param patterns: List of patterns to be used by this recognizer + :param context: List of context words to increase confidence in detection + :param supported_language: Language this recognizer supports + :param supported_entity: The entity this recognizer can detect + :param regex_flags: Regex flags to be used in regex matching + :param name: Name of the recognizer + """ + + PATTERNS = [ + # The IAM unique ID prefix table lists four credential prefixes: AKIA + # (access key), ASIA (temporary/STS access key ID), ABIA (STS service + # bearer token) and ACCA (context-specific credential). The remaining + # prefixes (AIDA, AROA, ANPA, ANVA, AGPA, AIPA, APKA, ASCA) identify + # users, roles, groups and policies -- they are identifiers, not + # credentials, and are deliberately excluded. + Pattern( + "AWS access key ID", + _case_sensitive(r"\b(?:ABIA|ACCA|AKIA|ASIA)[0-9A-Z]{16}\b"), + 0.9, + ), + # The value keeps the full base64 alphabet including ``=``. Thirty random + # bytes would encode to 40 characters with no padding, which would make + # ``=`` impossible -- 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 (a run of padding after the credential + # name) for a false negative on a real secret, which is the worse error + # here. ``=`` stays in the right-boundary lookahead so the documented + # exact length is still enforced. + Pattern( + "AWS secret access key", + _case_sensitive( + AWS_SECRET_ANCHOR + r"[A-Za-z0-9/+=]{40}(?![A-Za-z0-9/+=])" + ), + 0.8, + ), + # ghp_/gho_/ghu_/ghs_/ghr_ followed by 30 characters of base62 entropy + # and a 6 character base62 CRC32 checksum. GitHub App installation + # tokens (ghs_) are separate because GitHub also issues a stateless, + # JWT-format token and recommends accepting 36 or more characters from + # its documented character set. + Pattern( + "GitHub token", + _case_sensitive(r"\bgh[pour]_[A-Za-z0-9]{36}\b"), + 0.9, + ), + # GitHub's recommended ``ghs_[A-Za-z0-9.\-_]{36,}`` is written for + # validation, where a right boundary is unnecessary. Presidio reports a + # span, so the match must not end on ``.``: it is both a JWT segment + # separator and ordinary sentence punctuation, and a greedy match would + # otherwise report the period that ends a sentence as part of the + # credential. ``-`` and ``_`` stay admissible at the end because a + # base64url signature may legitimately end with either, and clipping one + # would under-report a real token. + # + # The 36-character minimum is spelled ``{35,}`` plus one final character + # so that the length is counted on what is *reported*. Asserting the + # length in a lookahead instead would count trailing dots that the + # consuming part then backtracks away, letting a short body such as + # ``ghs_A....`` be reported. + Pattern( + "GitHub App installation token", + _case_sensitive(r"\bghs_[A-Za-z0-9._-]{35,}[A-Za-z0-9_-]"), + 0.9, + ), + Pattern( + "GitHub fine-grained personal access token", + _case_sensitive(r"\bgithub_pat_[A-Za-z0-9_]{82}\b"), + 0.9, + ), + Pattern( + "Google API key", + _case_sensitive(r"\bAIza[0-9A-Za-z_-]{35}\b"), + 0.9, + ), + # Slack documents xoxb (bot), xoxp (user), xoxe- (rotation refresh), + # xoxe.xoxb-/xoxe.xoxp- (rotated access), xapp (app-level), and xwfp + # (workflow). The legacy xoxa/xoxr/xoxs prefixes are not in the current + # documentation and are left out. + Pattern( + "Slack bot or user token", + _case_sensitive(r"\bxox[bp]-[0-9A-Za-z-]{10,}"), + 0.85, + ), + Pattern( + "Slack refresh token", + _case_sensitive(r"\bxoxe-[0-9A-Za-z-]{10,}"), + 0.85, + ), + Pattern( + "Slack rotated access token", + _case_sensitive(r"\bxoxe\.xox[bp]-[0-9A-Za-z-]{10,}"), + 0.85, + ), + Pattern( + "Slack app-level token", + _case_sensitive(r"\bxapp-[0-9A-Za-z-]{10,}"), + 0.85, + ), + Pattern( + "Slack workflow token", + _case_sensitive(r"\bxwfp-[0-9A-Za-z-]{10,}"), + 0.85, + ), + # Secret (sk_) and restricted (rk_) keys are private in both live and + # sandbox modes. Publishable keys (pk_) are documented as safe to + # expose. + Pattern( + "Stripe secret or restricted key", + _case_sensitive(r"\b(?:sk|rk)_(?:live|test)_[0-9A-Za-z]{24,}"), + 0.9, + ), + # This intentionally covers the common compact signed JWT subset whose + # header and claims set both begin with '{"'. RFC 7519 also permits + # other JSON serialization and JWE forms; those do not have an equally + # precise textual marker and are outside this pattern's scope. + Pattern( + "Common compact signed JSON Web Token", + _case_sensitive( + r"\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\." + r"[A-Za-z0-9_-]{8,}" + ), + 0.6, + ), + ] + + CONTEXT = [ + "api", + "key", + "token", + "secret", + "credential", + "authorization", + "bearer", + ] + + def __init__( + self, + patterns: Optional[List[Pattern]] = None, + context: Optional[List[str]] = None, + supported_language: str = "en", + supported_entity: str = "API_KEY", + regex_flags: int = re.DOTALL | re.MULTILINE, + name: Optional[str] = None, + ): + patterns = patterns if patterns else self.PATTERNS + context = context if context else self.CONTEXT + super().__init__( + supported_entity=supported_entity, + patterns=patterns, + context=context, + supported_language=supported_language, + global_regex_flags=regex_flags, + name=name, + ) diff --git a/presidio-analyzer/tests/test_api_key_recognizer.py b/presidio-analyzer/tests/test_api_key_recognizer.py new file mode 100644 index 0000000000..4f74b9acb0 --- /dev/null +++ b/presidio-analyzer/tests/test_api_key_recognizer.py @@ -0,0 +1,350 @@ +import re + +import pytest +from presidio_analyzer.predefined_recognizers import ApiKeyRecognizer +from presidio_analyzer.recognizer_registry import RecognizerRegistryProvider + +from tests import assert_result_within_score_range + +# All credential values in this file are synthetic, except the AWS ones, which +# are the placeholders published in the AWS documentation. +# +# The Slack and Stripe values are assembled from fragments rather than written +# as literals. Secret scanners -- including GitHub push protection, which blocks +# the push outright -- match those two formats on shape alone, so a realistic +# literal would flag this test file. Splitting the prefix keeps the assembled +# string identical while leaving nothing for a scanner to match in the source. +SLACK_BOT_TOKEN = "xox" + "b-123456789012-1234567890123-EXAMPLEexampleEXAMPLEexam" +SLACK_APP_TOKEN = "xap" + "p-1-A01BCDEFGHI-1234567890123-EXAMPLEexample" +SLACK_WORKFLOW_TOKEN = "xwf" + "p-1-A01BCDEFGHI-1234567890123-EXAMPLEexample" +SLACK_REFRESH_TOKEN = "xox" + "e-1-A01BCDEFGHI-EXAMPLEexample" +SLACK_ROTATED_BOT_TOKEN = "xox" + "e.xoxb-1-A01BCDEFGHI-EXAMPLEexample" +SLACK_ROTATED_USER_TOKEN = "xox" + "e.xoxp-1-A01BCDEFGHI-EXAMPLEexample" +SLACK_LEGACY_TOKEN = "xox" + "s-123456789012-1234567890123-EXAMPLEexample" +STRIPE_SECRET_KEY = "sk" + "_live_0000EXAMPLEkey0000EXAMPLE00" +STRIPE_RESTRICTED_KEY = "rk" + "_live_0000EXAMPLEkey0000EXAMPLE00" +STRIPE_PUBLISHABLE_KEY = "pk" + "_live_0000EXAMPLEkey0000EXAMPLE00" +STRIPE_TEST_KEY = "sk" + "_test_0000EXAMPLEkey0000EXAMPLE00" +STRIPE_TEST_RESTRICTED_KEY = "rk" + "_test_0000EXAMPLEkey0000EXAMPLE00" +STRIPE_TEST_PUBLISHABLE_KEY = "pk" + "_test_0000EXAMPLEkey0000EXAMPLE00" +GITHUB_OPAQUE_INSTALLATION_TOKEN = "gh" + "s_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8" +# The signature segment is 152 characters (152 % 4 == 0) so that its final +# character is a full 6-bit base64url symbol and every one of the 64 symbols is +# reachable there. At 150 characters (150 % 4 == 2) the last character carries +# only 2 significant bits -- canonical unpadded base64url could then end only in +# A, Q, g or w, so the "-"/"_" fixtures below would not represent real tokens. +GITHUB_STATELESS_TOKEN = ( + "gh" + "s_123456789_" + "eyJ" + "A" * 140 + ".eyJ" + "B" * 200 + "." + "C" * 152 +) +# A stateless installation token ends in a base64url signature, which may +# legitimately end with "-" or "_". Pinning the span to alphanumeric would +# under-report these by one character. +GITHUB_STATELESS_TOKEN_DASH_END = GITHUB_STATELESS_TOKEN[:-1] + "-" +GITHUB_STATELESS_TOKEN_UNDERSCORE_END = GITHUB_STATELESS_TOKEN[:-1] + "_" + + +@pytest.fixture(scope="module") +def recognizer(): + """Return an ApiKeyRecognizer instance for testing.""" + return ApiKeyRecognizer() + + +@pytest.fixture(scope="module") +def entities(): + """Return the entity list this recognizer supports.""" + return ["API_KEY"] + + +@pytest.fixture(scope="module") +def default_registry_recognizer(): + """Load ApiKeyRecognizer through the shipped registry configuration.""" + registry = RecognizerRegistryProvider().create_recognizer_registry() + return next( + recognizer + for recognizer in registry.recognizers + if recognizer.name == "ApiKeyRecognizer" + ) + + +@pytest.mark.parametrize( + "text, expected_len, expected_positions, expected_score_ranges", + [ + # fmt: off + # --- AWS access key ID ------------------------------------------- + # AKIAIOSFODNN7EXAMPLE is the example value used in the AWS docs. + ("Access key: AKIAIOSFODNN7EXAMPLE", 1, ((12, 32),), ((0.9, 0.9),)), + # ASIA marks a temporary (STS) access key ID. + ("Temporary creds ASIAIOSFODNN7EXAMPLE issued", 1, ((16, 36),), ((0.9, 0.9),)), + # ABIA is an STS service bearer token, ACCA a context-specific + # credential -- both are credentials per the IAM prefix table. + ("ABIAIOSFODNN7EXAMPLE", 1, ((0, 20),), ((0.9, 0.9),)), + ("ACCAIOSFODNN7EXAMPLE", 1, ((0, 20),), ((0.9, 0.9),)), + # --- AWS secret access key --------------------------------------- + # Shared credentials file form. + ( + "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + 1, + ((24, 64),), + ((0.8, 0.8),), + ), + # Environment variable form. + ( + "AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + 1, + ((22, 62),), + ((0.8, 0.8),), + ), + # JSON form. Only the secret is reported, not the anchor. + ( + '"aws_secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"', + 1, + ((26, 66),), + ((0.8, 0.8),), + ), + # --- GitHub ------------------------------------------------------ + ( + "token ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8 here", + 1, + ((6, 46),), + ((0.9, 0.9),), + ), + ("gho_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8", 1, ((0, 40),), ((0.9, 0.9),)), + ( + GITHUB_OPAQUE_INSTALLATION_TOKEN, + 1, + ((0, len(GITHUB_OPAQUE_INSTALLATION_TOKEN)),), + ((0.9, 0.9),), + ), + (GITHUB_STATELESS_TOKEN, 1, ((0, len(GITHUB_STATELESS_TOKEN)),), ((0.9, 0.9),)), + # "." is in the installation-token character set, so a greedy match must + # not report the period that ends the sentence as part of the token. + ( + f"Rotate {GITHUB_OPAQUE_INSTALLATION_TOKEN}.", + 1, + ((7, 7 + len(GITHUB_OPAQUE_INSTALLATION_TOKEN)),), + ((0.9, 0.9),), + ), + ( + GITHUB_STATELESS_TOKEN_DASH_END, + 1, + ((0, len(GITHUB_STATELESS_TOKEN_DASH_END)),), + ((0.9, 0.9),), + ), + ( + GITHUB_STATELESS_TOKEN_UNDERSCORE_END, + 1, + ((0, len(GITHUB_STATELESS_TOKEN_UNDERSCORE_END)),), + ((0.9, 0.9),), + ), + ( + "github_pat_11ABCDEFG0EXAMPLEexamp_" + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456", + 1, + ((0, 93),), + ((0.9, 0.9),), + ), + # --- Google ------------------------------------------------------ + ( + "key AIzaSyD_ExampleKeyForTestingPurposes123 end", + 1, + ((4, 43),), + ((0.9, 0.9),), + ), + # --- Slack ------------------------------------------------------- + (SLACK_BOT_TOKEN, 1, ((0, 57),), ((0.85, 0.85),)), + (SLACK_APP_TOKEN, 1, ((0, 47),), ((0.85, 0.85),)), + (SLACK_WORKFLOW_TOKEN, 1, ((0, 47),), ((0.85, 0.85),)), + (SLACK_REFRESH_TOKEN, 1, ((0, len(SLACK_REFRESH_TOKEN)),), ((0.85, 0.85),)), + ( + SLACK_ROTATED_BOT_TOKEN, + 1, + ((0, len(SLACK_ROTATED_BOT_TOKEN)),), + ((0.85, 0.85),), + ), + ( + SLACK_ROTATED_USER_TOKEN, + 1, + ((0, len(SLACK_ROTATED_USER_TOKEN)),), + ((0.85, 0.85),), + ), + # --- Stripe ------------------------------------------------------ + (STRIPE_SECRET_KEY, 1, ((0, 35),), ((0.9, 0.9),)), + (STRIPE_RESTRICTED_KEY, 1, ((0, 35),), ((0.9, 0.9),)), + (STRIPE_TEST_KEY, 1, ((0, len(STRIPE_TEST_KEY)),), ((0.9, 0.9),)), + ( + STRIPE_TEST_RESTRICTED_KEY, + 1, + ((0, len(STRIPE_TEST_RESTRICTED_KEY)),), + ((0.9, 0.9),), + ), + # --- JWT --------------------------------------------------------- + ( + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." + "eyJzdWIiOiIxMjM0NTY3ODkwIn0." + "dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk", + 1, + ((7, 114),), + ((0.6, 0.6),), + ), + # --- Multiple credentials in one text ---------------------------- + ( + "id AKIAIOSFODNN7EXAMPLE and key ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8", + 2, + ((3, 23), (32, 72)), + ((0.9, 0.9), (0.9, 0.9)), + ), + # --- False positive prevention ----------------------------------- + # Vendor prefixes are case-sensitive. + ("akiaiosfodnn7example lowercase", 0, (), ()), + ("GHP_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8", 0, (), ()), + ("aizasyd_ExampleKeyForTestingPurposes123", 0, (), ()), + # A 40-character hex digest is not an AWS secret access key. + ("sha1 is 356a192b7913b04c54574d18c28d46e6395428ab here", 0, (), ()), + ("commit 5aa01c0d84f6de2c1a89b6c2b1e7dfa3c9d0e1b2 done", 0, (), ()), + # A bare 40-character base64 run carries no credential marker. + ("random 40 chars wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY alone", 0, (), ()), + # The documented secret length is exact; do not return a partial span. + ("AWS_SECRET_ACCESS_KEY=" + "A" * 39, 0, (), ()), + ("AWS_SECRET_ACCESS_KEY=" + "A" * 41, 0, (), ()), + # The value keeps the documented base64 alphabet including "=", so the + # exact-length right boundary is what rejects a longer padded run + # instead of reporting its first 40 characters. + ("AWS_SECRET_ACCESS_KEY=" + "A" * 40 + "=", 0, (), ()), + ("AWS_SECRET_ACCESS_KEY=" + "A" * 40 + "/", 0, (), ()), + # IAM unique ID prefixes identify roles/users, not credentials. + ("AROADBQP57FF2AEXAMPLE is a role unique id", 0, (), ()), + ("AIDACKCEVSQ6C2EXAMPLE is a user unique id", 0, (), ()), + ("AGPAIOSFODNN7EXAMPLE is a user group unique id", 0, (), ()), + # Stripe publishable keys are documented as safe to expose. + # Legacy Slack prefixes are not in the current documentation. + (SLACK_LEGACY_TOKEN, 0, (), ()), + (f"{STRIPE_PUBLISHABLE_KEY} publishable", 0, (), ()), + (f"{STRIPE_TEST_PUBLISHABLE_KEY} publishable", 0, (), ()), + # Wrong lengths. + ("ghp_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p", 0, (), ()), + ("AKIAIOSFODNN7EXAMPL", 0, (), ()), + # The installation-token minimum must be counted on the reported span, + # not on characters that trailing-"." backtracking gives back. Both of + # these reach 36 characters only by including dots that cannot end the + # span, so neither may be reported. + ("gh" + "s_" + "A" * 35 + ".", 0, (), ()), + ("gh" + "s_A" + "." * 35, 0, (), ()), + # Dotted base64 whose payload is not a JSON object is not a JWT. + ("eyJhbGciOiJIUzI1NiJ9.bm90LWEtanNvbi1wYXlsb2Fk.c2lnbmF0dXJlaGVyZQ", 0, (), ()), + # fmt: on + ], +) +def test_when_api_keys_then_succeed( + text, + expected_len, + expected_positions, + expected_score_ranges, + recognizer, + entities, + max_score, +): + """Verify ApiKeyRecognizer detects vendor credentials and rejects lookalikes.""" + results = recognizer.analyze(text, entities) + assert len(results) == expected_len + assert len(expected_positions) == expected_len + assert len(expected_score_ranges) == expected_len + for res, (st_pos, fn_pos), (st_score, fn_score) in zip( + results, expected_positions, expected_score_ranges + ): + if fn_score == "max": + fn_score = max_score + assert_result_within_score_range( + res, entities[0], st_pos, fn_pos, st_score, fn_score + ) + + +def test_when_secret_reported_then_anchor_excluded(recognizer, entities): + """The AWS anchor must not be part of the reported span.""" + text = "aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + results = recognizer.analyze(text, entities) + + assert len(results) == 1 + assert text[results[0].start : results[0].end] == ( + "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + ) + + +@pytest.mark.parametrize( + "text", + [ + "akiaiosfodnn7example lowercase", + "GHP_A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6Q7r8", + "aizasyd_ExampleKeyForTestingPurposes123", + ], +) +def test_when_loaded_from_default_registry_then_prefixes_remain_case_sensitive( + text, default_registry_recognizer, entities +): + """Registry-level IGNORECASE must not override credential prefix casing.""" + assert default_registry_recognizer.global_regex_flags & re.IGNORECASE + assert default_registry_recognizer.analyze(text, entities) == [] + + +def _case_scope_spans_whole_pattern(regex: str) -> bool: + """Return True if a leading ``(?-i:`` group closes only at the very end. + + Checking the prefix alone would accept a group that closes early, leaving + the rest of the pattern case-insensitive again under registry flags. Walk + the regex tracking parenthesis depth, skipping escapes and character + classes, and require the opening group to close on the last character. + """ + if not regex.startswith("(?-i:"): + return False + + depth = 0 + in_class = False + escaped = False + for index, char in enumerate(regex): + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif in_class: + if char == "]": + in_class = False + elif char == "[": + in_class = True + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth == 0: + return index == len(regex) - 1 + return False + + +def test_every_default_pattern_scopes_case_sensitivity(): + """A pattern added without the case-sensitive wrapper would silently widen. + + The scoping is applied per pattern, so an unwrapped addition still passes + every positive test while matching lowercase prose once the registry + applies IGNORECASE. Assert the invariant structurally instead. + """ + unscoped = [ + pattern.name + for pattern in ApiKeyRecognizer.PATTERNS + if not _case_scope_spans_whole_pattern(pattern.regex) + ] + assert unscoped == [] + + +@pytest.mark.parametrize( + "regex, expected", + [ + (r"(?-i:\bAKIA[0-9A-Z]{16}\b)", True), + (r"(?-i:(?:a|b)c)", True), + (r"(?-i:[)])", True), + (r"(?-i:\))", True), + # Closes early: everything after the group is case-insensitive again. + (r"(?-i:\bAKIA)[0-9A-Z]{16}", False), + (r"\bAKIA[0-9A-Z]{16}\b", False), + ], +) +def test_case_scope_detection(regex, expected): + """The scope check must reject a group that does not enclose the pattern.""" + assert _case_scope_spans_whole_pattern(regex) is expected