-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpix_analyzer.py
More file actions
294 lines (266 loc) · 11.5 KB
/
Copy pathpix_analyzer.py
File metadata and controls
294 lines (266 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
modules/pix_analyzer.py
Analisador de autenticidade e triangulação de metadados Pix.
Created by psyhusk
"""
import re
import hashlib
import datetime
from typing import Any
# ── Padrões suspeitos conhecidos ──────────────────────────────────────
SUSPICIOUS_NAMES = [
"teste", "test", "fraude", "golpe", "falso", "fake",
"admin", "suporte", "atendimento pix", "central pix",
"banco do brasil falso", "itau", "itaú", "bradesco falso",
]
SUSPICIOUS_URLS = [
r"bit\.ly", r"t\.co", r"goo\.gl", r"tinyurl",
r"ow\.ly", r"shorturl", r"cutt\.ly",
r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}", # IP direto
r"ngrok", r"localhost",
]
OFFICIAL_PIX_DOMAINS = [
"pix.bcb.gov.br",
"pix.bb.com.br",
"pix.itau.com.br",
"pix.bradesco.com.br",
"pix.santander.com.br",
"pix.caixa.gov.br",
"nubank.com.br",
"api.infinitepay.io",
"pix.mercadopago.com",
]
VALID_MCC_CODES = {
"0000": "Não especificado (genérico)",
"5411": "Supermercado",
"5912": "Farmácia",
"5999": "Comércio variado",
"7372": "Serviços de software",
"8099": "Serviços de saúde",
"8211": "Escola / Ensino",
"5812": "Restaurante",
"4111": "Transporte",
"5311": "Loja de departamento",
"7011": "Hotel / Hospedagem",
"5732": "Eletrônica",
"5661": "Calçados",
"5621": "Vestuário feminino",
}
CPF_CNPJ_BLACKLIST = set() # Pode ser populado com base de dados externos
class PixAnalyzer:
"""
Analisa um payload Pix decodificado e retorna:
- score (0–100): 100 = totalmente legítimo
- flags: lista de alertas textuais
- checks: lista de verificações detalhadas
- nivel: BAIXO / MÉDIO / ALTO
"""
def analyze(self, decoded: dict) -> dict[str, Any]:
meta = decoded.get("metadata", {})
fields = decoded.get("fields", [])
fmt = decoded.get("format", "Estático")
score = 100
flags = []
checks = []
# ── 1. CRC-16 ─────────────────────────────────────────────────
crc_ok = decoded.get("crc_valid", False)
score -= 0 if crc_ok else 30
if not crc_ok:
flags.append("CRC-16 inválido — payload adulterado ou corrompido")
checks.append({
"label": "CRC-16 (integridade do payload)",
"ok": crc_ok,
"detail": f"Calculado={decoded.get('crc_calculated','?')} / Payload={decoded.get('crc_payload','?')}",
})
# ── 2. Formato correto (começa com 000201) ────────────────────
payload = decoded.get("payload", "")
fmt_ok = payload.startswith("000201") or payload.startswith("000201".lower())
score -= 0 if fmt_ok else 10
if not fmt_ok:
flags.append("Payload não inicia com 000201 — formato EMV inválido")
checks.append({
"label": "Indicador de formato EMV (000201)",
"ok": fmt_ok,
"detail": payload[:6] if payload else "vazio",
})
# ── 3. Campo obrigatório 59 (Nome do beneficiário) ────────────
nome = meta.get("nome", "")
nome_ok = bool(nome and len(nome) >= 2)
score -= 0 if nome_ok else 10
if not nome_ok:
flags.append("Nome do beneficiário ausente ou muito curto")
checks.append({
"label": "Nome do beneficiário (campo 59)",
"ok": nome_ok,
"detail": nome[:40] if nome else "—",
})
# ── 4. Nome suspeito ──────────────────────────────────────────
nome_lower = nome.lower()
nome_susp = any(s in nome_lower for s in SUSPICIOUS_NAMES)
score -= 20 if nome_susp else 0
if nome_susp:
flags.append(f"Nome do beneficiário suspeito: '{nome}'")
checks.append({
"label": "Nome não está em lista de suspeitos",
"ok": not nome_susp,
"detail": "Nome corresponde a padrões fraudulentos" if nome_susp else "OK",
})
# ── 5. Chave Pix válida ────────────────────────────────────────
chave = meta.get("chave", "")
tipo_chave = meta.get("tipo_chave", "")
chave_ok = bool(chave and tipo_chave not in ["Desconhecido", ""])
score -= 0 if chave_ok else 15
if not chave_ok:
flags.append("Chave Pix ausente ou tipo não reconhecido")
checks.append({
"label": "Chave Pix presente e identificada",
"ok": chave_ok,
"detail": f"{tipo_chave}: {chave[:40]}" if chave else "—",
})
# ── 6. CPF/CNPJ formato ───────────────────────────────────────
if tipo_chave == "CPF":
cpf_ok = self._validate_cpf(chave)
score -= 0 if cpf_ok else 20
if not cpf_ok:
flags.append(f"CPF '{chave}' inválido (dígito verificador incorreto)")
checks.append({
"label": "CPF com dígito verificador válido",
"ok": cpf_ok,
"detail": chave,
})
elif tipo_chave == "CNPJ":
cnpj_ok = self._validate_cnpj(chave)
score -= 0 if cnpj_ok else 20
if not cnpj_ok:
flags.append(f"CNPJ '{chave}' inválido (dígito verificador incorreto)")
checks.append({
"label": "CNPJ com dígito verificador válido",
"ok": cnpj_ok,
"detail": chave,
})
# ── 7. Valor suspeito (>0 e razoável) ─────────────────────────
valor_str = meta.get("valor", "")
if valor_str:
try:
valor_num = float(valor_str.replace("R$", "").replace(",", ".").strip())
valor_ok = 0 < valor_num <= 999999.99
if not valor_ok:
flags.append(f"Valor suspeito: {valor_str}")
score -= 10
checks.append({
"label": "Valor da transação dentro do limite aceitável",
"ok": valor_ok,
"detail": valor_str,
})
except ValueError:
flags.append(f"Valor não parseável: {valor_str}")
checks.append({"label": "Valor parseável", "ok": False, "detail": valor_str})
# ── 8. URL do payload dinâmico ────────────────────────────────
url = meta.get("url", "")
if url:
url_oficial = any(d in url for d in OFFICIAL_PIX_DOMAINS)
url_susp = any(re.search(p, url) for p in SUSPICIOUS_URLS)
if url_susp:
flags.append(f"URL do payload com domínio suspeito: {url}")
score -= 20
if not url_oficial and not url_susp:
flags.append(f"URL do payload em domínio não verificado: {url}")
score -= 5
checks.append({
"label": "URL do payload em domínio oficial Pix",
"ok": url_oficial and not url_susp,
"detail": url[:60],
})
else:
checks.append({
"label": "Payload estático (sem URL externa)",
"ok": True,
"detail": "Pix estático — risco de URL ausente",
})
# ── 9. Código de país BR ──────────────────────────────────────
pais = meta.get("pais", "")
pais_ok = pais.upper() == "BR" if pais else True # ausente = não penaliza
if pais and not pais_ok:
flags.append(f"País inválido no payload: '{pais}' (esperado 'BR')")
score -= 10
if pais:
checks.append({
"label": "Código de país = BR",
"ok": pais_ok,
"detail": pais,
})
# ── 10. Moeda BRL ─────────────────────────────────────────────
moeda = meta.get("moeda", "")
moeda_ok = moeda == "BRL" if moeda else True
if moeda and not moeda_ok:
flags.append(f"Moeda inválida: '{moeda}' (esperado 'BRL')")
score -= 10
if moeda:
checks.append({
"label": "Moeda = BRL (986)",
"ok": moeda_ok,
"detail": moeda,
})
# ── 11. GUI Pix correto ───────────────────────────────────────
gui = meta.get("gui", "")
gui_ok = gui.lower() == "br.gov.bcb.pix"
if gui and not gui_ok:
flags.append(f"GUI (identificador Pix) inválido: '{gui}'")
score -= 15
checks.append({
"label": "GUI = br.gov.bcb.pix",
"ok": gui_ok,
"detail": gui or "—",
})
# ── 12. Comprimento mínimo do payload ─────────────────────────
len_ok = len(payload) >= 30
if not len_ok:
flags.append("Payload muito curto — possível truncamento ou payload falso")
score -= 15
checks.append({
"label": "Comprimento mínimo do payload (≥30 chars)",
"ok": len_ok,
"detail": f"{len(payload)} caracteres",
})
score = max(0, min(100, score))
nivel = "BAIXO" if score >= 80 else ("MÉDIO" if score >= 50 else "ALTO")
return {
"score": score,
"nivel": nivel,
"flags": flags,
"checks": checks,
"details": {
"crc_valid": crc_ok,
"nome": nome,
"chave": chave,
"tipo_chave": tipo_chave,
"url": url,
"formato": fmt,
}
}
# ── Validadores ───────────────────────────────────────────────────
def _validate_cpf(self, cpf: str) -> bool:
cpf = re.sub(r"\D", "", cpf)
if len(cpf) != 11 or cpf == cpf[0] * 11:
return False
for i in range(9, 11):
s = sum(int(cpf[j]) * (i + 1 - j) for j in range(i))
d = (s * 10 % 11) % 10
if d != int(cpf[i]):
return False
return True
def _validate_cnpj(self, cnpj: str) -> bool:
cnpj = re.sub(r"\D", "", cnpj)
if len(cnpj) != 14 or cnpj == cnpj[0] * 14:
return False
weights1 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
weights2 = [6] + weights1
for weights, pos in [(weights1, 12), (weights2, 13)]:
s = sum(int(cnpj[i]) * w for i, w in enumerate(weights))
r = s % 11
d = 0 if r < 2 else 11 - r
if d != int(cnpj[pos]):
return False
return True