starshield-forensics 0.3.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Validated Multi-Jurisdiction Financial Indicator Extractor (`ioc_validators.py`).
5
+
6
+ Implements standard banking format and check-digit verification:
7
+ - Payment Cards (ISO/IEC 7812): Luhn check-digit verification; cleanly separates
8
+ `luhn_validated_cards` from `unvalidated_card_candidates`.
9
+ - IBAN (ISO 13616): Complete SWIFT IBAN Registry country-length table (rejecting
10
+ unregistered country codes like `ZZ..`, supporting 15-to-34 char IBANs including
11
+ 15-char Norway `NO` and 30-char Yemen `YE`) + Rearrange + `mod 97 == 1` checksum.
12
+ - SWIFT / BIC (ISO 9362): Complete 249-code ISO 3166-1 alpha-2 table (plus `XK`) at
13
+ chars 4..6 AND explicit banking/SWIFT context check for 8-letter all-alpha tokens
14
+ so ordinary English words (`WORKSHOP`, `DOCUMENT`, `ABCDEFGH`) are never misflagged.
15
+ - UK Sort Code + Account Number: Restricts 8-digit account pairing to a local
16
+ 120-character window around a Sort Code strictly inside the SAME single message.
17
+ - Keyed Ephemeral Redaction: Default redaction uses per-run `HMAC-SHA256` tokens rather
18
+ than partial prefix/suffix masking to prevent dictionary confirmation across reports.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import hmac
25
+ import re
26
+ import secrets
27
+ from typing import Any, Dict, List
28
+
29
+ DEFAULT_EPHEMERAL_SALT = secrets.token_bytes(32)
30
+
31
+ # Complete official ISO 3166-1 alpha-2 country codes (249 codes) + XK (SWIFT Kosovo)
32
+ ISO_3166_ALPHA2 = {
33
+ "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU",
34
+ "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL",
35
+ "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC",
36
+ "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV",
37
+ "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG",
38
+ "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD",
39
+ "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT",
40
+ "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM",
41
+ "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH",
42
+ "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK",
43
+ "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH",
44
+ "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW",
45
+ "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR",
46
+ "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR",
47
+ "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC",
48
+ "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS",
49
+ "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL",
50
+ "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY",
51
+ "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "XK", "YE", "YT",
52
+ "ZA", "ZM", "ZW",
53
+ }
54
+
55
+ # Official ISO 13616 / SWIFT IBAN Registry (all 89 registered jurisdictions)
56
+ IBAN_LENGTHS = {
57
+ "AD": 24, "AE": 23, "AL": 28, "AT": 20, "AZ": 28, "BA": 20, "BE": 16, "BG": 22,
58
+ "BH": 22, "BI": 27, "BR": 29, "BY": 28, "CH": 21, "CR": 22, "CY": 28, "CZ": 24,
59
+ "DE": 22, "DJ": 27, "DK": 18, "DO": 28, "EE": 20, "EG": 29, "ES": 24, "FI": 18,
60
+ "FK": 18, "FO": 18, "FR": 27, "GB": 22, "GE": 22, "GI": 23, "GL": 18, "GR": 27,
61
+ "GT": 28, "HN": 28, "HR": 21, "HU": 28, "IE": 22, "IL": 23, "IQ": 23, "IS": 26,
62
+ "IT": 27, "JO": 30, "KW": 30, "KZ": 20, "LB": 28, "LC": 32, "LI": 21, "LT": 20,
63
+ "LU": 20, "LV": 21, "LY": 25, "MC": 27, "MD": 24, "ME": 22, "MK": 19, "MN": 20,
64
+ "MR": 27, "MT": 31, "MU": 30, "NI": 28, "NL": 18, "NO": 15, "OM": 23, "PK": 24,
65
+ "PL": 28, "PS": 29, "PT": 25, "QA": 29, "RO": 24, "RS": 22, "RU": 33, "SA": 24,
66
+ "SC": 31, "SD": 18, "SE": 24, "SI": 19, "SK": 24, "SM": 27, "SO": 23, "ST": 25,
67
+ "SV": 28, "TL": 23, "TN": 24, "TR": 26, "UA": 29, "VA": 22, "VG": 24, "XK": 20,
68
+ "YE": 30,
69
+ }
70
+
71
+ COMMON_ENGLISH_8LETTER_STOPWORDS = {
72
+ "WORKSHOP", "TRANSFER", "DOCUMENT", "SECURITY", "PASSPORT", "OFFICIAL",
73
+ "CHECKING", "RECEIVED", "REQUIRED", "RESPONSE", "EVIDENCE", "NATIONAL",
74
+ "CUSTOMER", "PERSONAL", "BUSINESS", "APPROVED", "REJECTED", "COMPLETE",
75
+ }
76
+
77
+
78
+ def empty_ioc_dict() -> Dict[str, List[Any]]:
79
+ return {
80
+ "luhn_validated_cards": [],
81
+ "unvalidated_card_candidates": [],
82
+ "mod97_validated_ibans": [],
83
+ "context_paired_uk_accounts": [],
84
+ "unpaired_uk_sort_codes": [],
85
+ "validated_swift_bics": [],
86
+ }
87
+
88
+
89
+ def merge_ioc_dicts(dst: Dict[str, List[Any]], src: Dict[str, List[Any]]) -> None:
90
+ """Merge per-message IOC results without cross-message proximity contamination."""
91
+ for k, items in src.items():
92
+ bucket = dst.setdefault(k, [])
93
+ for item in items:
94
+ if item not in bucket:
95
+ bucket.append(item)
96
+
97
+
98
+ def luhn_valid(digits: str) -> bool:
99
+ if not digits.isdigit() or not (13 <= len(digits) <= 19):
100
+ return False
101
+ total = 0
102
+ reverse_digits = digits[::-1]
103
+ for idx, ch in enumerate(reverse_digits):
104
+ n = int(ch)
105
+ if idx % 2 == 1:
106
+ n *= 2
107
+ if n > 9:
108
+ n -= 9
109
+ total += n
110
+ return total % 10 == 0
111
+
112
+
113
+ def iban_mod97_valid(iban: str) -> bool:
114
+ cleaned = re.sub(r"[\s-]+", "", iban).upper()
115
+ if len(cleaned) < 15 or len(cleaned) > 34:
116
+ return False
117
+ if not cleaned[:2].isalpha() or not cleaned[2:4].isdigit() or not cleaned.isalnum():
118
+ return False
119
+ country = cleaned[:2]
120
+ expected_len = IBAN_LENGTHS.get(country)
121
+ if expected_len is None or len(cleaned) != expected_len:
122
+ return False
123
+
124
+ rearranged = cleaned[4:] + cleaned[:4]
125
+ numeric_str = "".join(str(ord(c) - 55) if c.isalpha() else c for c in rearranged)
126
+ return int(numeric_str) % 97 == 1
127
+
128
+
129
+ SUPPORTED_HMAC_DIGESTS = {
130
+ "sha256": hashlib.sha256,
131
+ "sha384": hashlib.sha384,
132
+ "blake2b": hashlib.blake2b,
133
+ }
134
+ _ACTIVE_HMAC_DIGEST = "sha256"
135
+
136
+
137
+ def set_active_hmac_digest(alg: str) -> None:
138
+ global _ACTIVE_HMAC_DIGEST
139
+ if alg not in SUPPORTED_HMAC_DIGESTS:
140
+ raise ValueError(f"Unsupported HMAC digest algorithm: {alg}")
141
+ _ACTIVE_HMAC_DIGEST = alg
142
+
143
+
144
+ def get_active_hmac_callable():
145
+ return SUPPORTED_HMAC_DIGESTS[_ACTIVE_HMAC_DIGEST]
146
+
147
+
148
+ def mask_identifier(val: str, salt: bytes | None = None, digest_alg: str | None = None) -> str:
149
+ """Keyed HMAC redaction with cryptographic algorithm agility (resists dictionary confirmation)."""
150
+ if not val:
151
+ return ""
152
+ key = salt if salt is not None else DEFAULT_EPHEMERAL_SALT
153
+ fn = SUPPORTED_HMAC_DIGESTS[digest_alg] if digest_alg else get_active_hmac_callable()
154
+ digest = hmac.new(key, val.encode("utf-8"), fn).hexdigest()[:12]
155
+ return f"[REDACTED_HMAC:{digest}]"
156
+
157
+
158
+ def extract_validated_iocs(
159
+ text: str,
160
+ redact: bool = True,
161
+ salt: bytes | None = None,
162
+ ) -> Dict[str, Any]:
163
+ out = empty_ioc_dict()
164
+ if not text:
165
+ return out
166
+
167
+ def fmt(s: str) -> str:
168
+ return mask_identifier(s, salt=salt) if redact else s
169
+
170
+ # 1. ISO/IEC 7812 payment cards (major network IIN ranges 4/5/62) allowing spaces or hyphens
171
+ for m in re.finditer(r"(?<!\d)(?:4\d|5[1-5]|62)(?:[\s-]?\d){11,17}(?!\d)", text):
172
+ digits = re.sub(r"[\s-]", "", m.group(0))
173
+ if not (13 <= len(digits) <= 19):
174
+ continue
175
+ rendered = fmt(digits)
176
+ if luhn_valid(digits):
177
+ if rendered not in out["luhn_validated_cards"]:
178
+ out["luhn_validated_cards"].append(rendered)
179
+ else:
180
+ if rendered not in out["unvalidated_card_candidates"]:
181
+ out["unvalidated_card_candidates"].append(rendered)
182
+
183
+ # 2. IBAN (ISO 13616 Mod-97 + strict registered country length 15..34)
184
+ for m in re.finditer(r"\b[A-Z]{2}\d{2}(?:[\s-]?[A-Z0-9]{2,4}){2,8}(?:[\s-]?[A-Z0-9]{1,3})?\b", text):
185
+ cand = re.sub(r"[\s-]+", "", m.group(0))
186
+ if iban_mod97_valid(cand):
187
+ rendered = fmt(cand)
188
+ if rendered not in out["mod97_validated_ibans"]:
189
+ out["mod97_validated_ibans"].append(rendered)
190
+
191
+ # 3. UK Sort Code + local-window (<=120 chars) 8-digit Account Number pairing (intra-message only)
192
+ for sc_match in re.finditer(r"(?<!\d)\d{2}-\d{2}-\d{2}(?!\d)", text):
193
+ sc = sc_match.group(0)
194
+ win_start = max(0, sc_match.start() - 120)
195
+ win_end = min(len(text), sc_match.end() + 120)
196
+ window = text[win_start:win_end]
197
+ acc_matches = re.findall(r"(?<!\d)\d{8}(?!\d)", window)
198
+ if acc_matches:
199
+ for acc in acc_matches:
200
+ pair = {
201
+ "sort_code": fmt(sc),
202
+ "account_number": fmt(acc),
203
+ "validation_note": "Context-paired within 120 chars in same message (Pay.UK Vocalink modulus table not bundled)",
204
+ }
205
+ if pair not in out["context_paired_uk_accounts"]:
206
+ out["context_paired_uk_accounts"].append(pair)
207
+ else:
208
+ rendered_sc = fmt(sc)
209
+ if rendered_sc not in out["unpaired_uk_sort_codes"]:
210
+ out["unpaired_uk_sort_codes"].append(rendered_sc)
211
+
212
+ # 4. SWIFT / BIC (ISO 9362): valid ISO 3166 country at [4:6] + stopword rejection + context check
213
+ has_swift_ctx = bool(re.search(r"\b(swift|bic|routing|wire|beneficiary|iban)\b", text, re.IGNORECASE))
214
+ for bic_m in re.finditer(r"\b([A-Z]{4})([A-Z]{2})([A-Z0-9]{2})([A-Z0-9]{3})?\b", text):
215
+ full_bic = bic_m.group(0)
216
+ country = bic_m.group(2)
217
+ branch = bic_m.group(4)
218
+ if country not in ISO_3166_ALPHA2:
219
+ continue
220
+ if full_bic in COMMON_ENGLISH_8LETTER_STOPWORDS:
221
+ continue
222
+ if len(full_bic) == 8 and full_bic.isalpha() and not has_swift_ctx:
223
+ continue
224
+ if branch is None or len(full_bic) in (8, 11):
225
+ rendered_bic = fmt(full_bic)
226
+ if rendered_bic not in out["validated_swift_bics"]:
227
+ out["validated_swift_bics"].append(rendered_bic)
228
+
229
+ return out