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

1import logging 

2import os 

3import string 

4from typing import Dict, List, Optional, Tuple 

5 

6import regex as re 

7 

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) 

20 

21logger = logging.getLogger("presidio-analyzer") 

22 

23REGEX_TIMEOUT_SECONDS = int(os.environ.get("REGEX_TIMEOUT_SECONDS", 60)) 

24 

25class IbanRecognizer(PatternRecognizer): 

26 """ 

27 Recognize IBAN code using regex and checksum. 

28 

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 """ 

41 

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 ] 

62 

63 CONTEXT = ["iban", "bank", "transaction"] 

64 

65 LETTERS: Dict[int, str] = { 

66 ord(d): str(i) for i, d in enumerate(string.digits + string.ascii_uppercase) 

67 } 

68 

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 ) 

94 

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 

115 

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 = [] 

125 

126 if self.patterns: 

127 pattern_result = self.__analyze_patterns(text) 

128 results.extend(pattern_result) 

129 

130 return results 

131 

132 def __analyze_patterns(self, text: str, flags: int = None): 

133 """ 

134 Evaluate all patterns in the provided text. 

135 

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. 

139 

140 i.e. "I want my deposit in DE89370400440532013000 2 days from today." 

141 

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 ) 

153 

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] 

163 

164 # Skip empty results 

165 if current_match == "": 

166 continue 

167 

168 score = pattern.score 

169 

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 ) 

190 

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 

196 

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 ) 

207 

208 return results 

209 

210 @staticmethod 

211 def __number_iban(iban: str, letters: Dict[int, str]) -> str: 

212 return (iban[4:] + iban[:4]).translate(letters) 

213 

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}" 

219 

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 

242 

243 return False