Coverage for presidio_analyzer / predefined_recognizers / generic / iban_recognizer.py: 93%
89 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-29 09:03 +0000
« prev ^ index » next coverage.py v7.13.1, created at 2026-03-29 09:03 +0000
1import logging
2import os
3import string
4from typing import Dict, List, Optional, Tuple
6import regex as re
8from presidio_analyzer import (
9 EntityRecognizer,
10 Pattern,
11 PatternRecognizer,
12 RecognizerResult,
13)
14from presidio_analyzer.nlp_engine import NlpArtifacts
15from presidio_analyzer.predefined_recognizers.generic.iban_patterns import (
16 BOS,
17 EOS,
18 regex_per_country,
19)
21logger = logging.getLogger("presidio-analyzer")
23REGEX_TIMEOUT_SECONDS = int(os.environ.get("REGEX_TIMEOUT_SECONDS", 60))
25class IbanRecognizer(PatternRecognizer):
26 """
27 Recognize IBAN code using regex and checksum.
29 :param patterns: List of patterns to be used by this recognizer
30 :param context: List of context words to increase confidence in detection
31 :param supported_language: Language this recognizer supports
32 :param supported_entity: The entity this recognizer can detect
33 :param exact_match: Whether patterns should be exactly matched or not
34 :param bos_eos: Tuple of strings for beginning of string (BOS)
35 and end of string (EOS)
36 :param regex_flags: Regex flags options
37 :param replacement_pairs: List of tuples with potential replacement values
38 for different strings to be used during pattern matching.
39 This can allow a greater variety in input, for example by removing dashes or spaces.
40 """
42 # Pattern explanation:
43 # - (?<![A-Z0-9]): Negative lookbehind - ensures we don't start mid-IBAN
44 # - ([A-Z]{2}[0-9]{2}(?:[ -]?[A-Z0-9]{4}){2,6}): First capture group with
45 # country code, check digits, and 2-6 groups of 4 alphanumerics
46 # - ((?:[ -]?[A-Z0-9]{4})?): Second optional capture group for 1 more group of 4
47 # - ((?:[ -]?[A-Z0-9]{1,3})?): Third optional capture group for trailing 1-3 chars
48 # - (?![A-Z0-9]): Negative lookahead - ensures we don't end mid-IBAN
49 #
50 # Multiple capture groups enable validation fallback: the __analyze_patterns method
51 # iterates through groups in reverse order (3→2→1), trying progressively shorter
52 # matches. Example: for "IBAN123456 X", tries "IBAN123456 X" (fails validation),
53 # then "IBAN123456" (passes), avoiding false positives from trailing characters.
54 PATTERNS = [
55 Pattern(
56 "IBAN Generic",
57 r"(?<![A-Z0-9])([A-Z]{2}[0-9]{2}(?:[ -]?[A-Z0-9]{4}){2,6})"
58 r"((?:[ -]?[A-Z0-9]{4})?)((?:[ -]?[A-Z0-9]{1,3})?)(?![A-Z0-9])",
59 0.5,
60 ),
61 ]
63 CONTEXT = ["iban", "bank", "transaction"]
65 LETTERS: Dict[int, str] = {
66 ord(d): str(i) for i, d in enumerate(string.digits + string.ascii_uppercase)
67 }
69 def __init__(
70 self,
71 patterns: List[str] = None,
72 context: List[str] = None,
73 supported_language: str = "en",
74 supported_entity: str = "IBAN_CODE",
75 exact_match: bool = False,
76 bos_eos: Tuple[str, str] = (BOS, EOS),
77 regex_flags: int = re.DOTALL | re.MULTILINE,
78 replacement_pairs: Optional[List[Tuple[str, str]]] = None,
79 name: Optional[str] = None,
80 ):
81 self.replacement_pairs = replacement_pairs or [("-", ""), (" ", "")]
82 self.exact_match = exact_match
83 self.BOSEOS = bos_eos if exact_match else ()
84 patterns = patterns if patterns else self.PATTERNS
85 context = context if context else self.CONTEXT
86 super().__init__(
87 supported_entity=supported_entity,
88 patterns=patterns,
89 context=context,
90 supported_language=supported_language,
91 global_regex_flags=regex_flags,
92 name=name,
93 )
95 def validate_result(self, pattern_text: str): # noqa: D102
96 try:
97 pattern_text = EntityRecognizer.sanitize_value(
98 pattern_text, self.replacement_pairs
99 )
100 is_valid_checksum = (
101 self.__generate_iban_check_digits(pattern_text, self.LETTERS)
102 == pattern_text[2:4]
103 )
104 # score = EntityRecognizer.MIN_SCORE
105 result = False
106 if is_valid_checksum:
107 if self.__is_valid_format(pattern_text, self.BOSEOS):
108 result = True
109 elif self.__is_valid_format(pattern_text.upper(), self.BOSEOS):
110 result = None
111 return result
112 except ValueError:
113 logger.error("Failed to validate text %s", pattern_text)
114 return False
116 def analyze(
117 self,
118 text: str,
119 entities: List[str],
120 nlp_artifacts: NlpArtifacts = None,
121 regex_flags: int = None,
122 ) -> List[RecognizerResult]:
123 """Analyze IBAN."""
124 results = []
126 if self.patterns:
127 pattern_result = self.__analyze_patterns(text)
128 results.extend(pattern_result)
130 return results
132 def __analyze_patterns(self, text: str, flags: int = None):
133 """
134 Evaluate all patterns in the provided text.
136 Logic includes detecting words in the provided deny list.
137 In a sentence we could get a false positive at the end of our regex, were we
138 want to find the IBAN but not the false positive at the end of the match.
140 i.e. "I want my deposit in DE89370400440532013000 2 days from today."
142 :param text: text to analyze
143 :param flags: regex flags
144 :return: A list of RecognizerResult
145 """
146 flags = flags if flags else self.global_regex_flags
147 results = []
148 for pattern in self.patterns:
149 try:
150 matches = re.finditer(
151 pattern.regex, text, flags=flags, timeout=REGEX_TIMEOUT_SECONDS
152 )
154 for match in matches:
155 for grp_num in reversed(range(1, len(match.groups()) + 1)):
156 start = match.span(0)[0]
157 end = (
158 match.span(grp_num)[1]
159 if match.span(grp_num)[1] > 0
160 else match.span(0)[1]
161 )
162 current_match = text[start:end]
164 # Skip empty results
165 if current_match == "":
166 continue
168 score = pattern.score
170 validation_result = self.validate_result(current_match)
171 description = PatternRecognizer.build_regex_explanation(
172 self.name,
173 pattern.name,
174 pattern.regex,
175 score,
176 validation_result,
177 flags,
178 )
179 pattern_result = RecognizerResult(
180 entity_type=self.supported_entities[0],
181 start=start,
182 end=end,
183 score=score,
184 analysis_explanation=description,
185 recognition_metadata={
186 RecognizerResult.RECOGNIZER_NAME_KEY: self.name,
187 RecognizerResult.RECOGNIZER_IDENTIFIER_KEY: self.id,
188 },
189 )
191 if validation_result is not None:
192 if validation_result:
193 pattern_result.score = EntityRecognizer.MAX_SCORE
194 else:
195 pattern_result.score = EntityRecognizer.MIN_SCORE
197 if pattern_result.score > EntityRecognizer.MIN_SCORE:
198 results.append(pattern_result)
199 break
200 except TimeoutError:
201 logger.warning(
202 "Regex pattern '%s' timed out after %s seconds, skipping.",
203 pattern.name,
204 REGEX_TIMEOUT_SECONDS,
205 exc_info=True,
206 )
208 return results
210 @staticmethod
211 def __number_iban(iban: str, letters: Dict[int, str]) -> str:
212 return (iban[4:] + iban[:4]).translate(letters)
214 @staticmethod
215 def __generate_iban_check_digits(iban: str, letters: Dict[int, str]) -> str:
216 transformed_iban = (iban[:2] + "00" + iban[4:]).upper()
217 number_iban = IbanRecognizer.__number_iban(transformed_iban, letters)
218 return f"{98 - (int(number_iban) % 97):0>2}"
220 @staticmethod
221 def __is_valid_format(
222 iban: str,
223 bos_eos: Tuple[str, str] = (BOS, EOS),
224 flags: int = re.DOTALL | re.MULTILINE,
225 ) -> bool:
226 country_code = iban[:2]
227 if country_code in regex_per_country:
228 country_regex = regex_per_country.get(country_code, "")
229 if bos_eos and country_regex:
230 country_regex = bos_eos[0] + country_regex + bos_eos[1]
231 try:
232 return country_regex and re.match(
233 country_regex, iban, flags=flags, timeout=REGEX_TIMEOUT_SECONDS
234 )
235 except TimeoutError:
236 logger.warning(
237 "IBAN format validation regex timed out after %s seconds.",
238 REGEX_TIMEOUT_SECONDS,
239 exc_info=True,
240 )
241 return False
243 return False