Tecnología · GUÍA PRÁCTICA
¿Bitwarden dice que terminó? Audita los adjuntos ZIP antes de borrar la bóveda
Una auditoría local del ZIP de Bitwarden inventaría y calcula hashes de los archivos presentes, pero no demuestra por sí sola que todos los adjuntos del servidor se exportaron. Comprueba la estructura sin conexión, compara con una referencia independiente y determina cuándo mantener la fuente y suspender la eliminación o migración.

No elimines una cuenta Bitwarden, una bóveda de organización ni el dispositivo original solo porque apareció un mensaje de éxito. Confirma primero que existe el ZIP, crea una copia de trabajo y audítala sin conexión. El resultado más seguro no es permiso para borrar, sino un inventario documentado que puedas comparar con una lista independiente de lo esperado.
El código del cliente inspeccionado escribe data.json y attachments/, pero su modelo JSON exportado no lleva metadatos de adjuntos. Una auditoría local puede inventariar y calcular hashes de los miembros presentes. El ZIP no establece por sí solo que un adjunto ausente del servidor formaba parte de lo esperado.
1. Suspende el paso destructivo
Mantén intactos la bóveda y la cuenta originales. Copia el ZIP descargado a un directorio local de trabajo, desconecta su sincronización en la nube si es práctico y no lo subas a verificadores ZIP en línea. Una exportación sin cifrar puede contener contraseñas, notas, datos de identidad y contenidos adjuntos. Trata data.json y cualquier informe derivado como información sensible.
Confirma que el archivo existe y registra su tamaño antes de actuar. Si la aplicación indicó éxito pero no escribió un archivo, detente: no hay nada que auditar. Exporta de nuevo mediante un cliente oficial o resuelve el problema manteniendo la fuente. Una notificación de éxito no demuestra que un archivo utilizable llegó al disco.
2. Entiende el escritor ZIP inspeccionado
En el commit de Bitwarden clients df9641824436b65207f393d35b8d07225129797b inspeccionado, el escritor individual añade data.json y attachments/, y excluye elementos eliminados o de organizaciones al recopilar adjuntos. Sustituye caracteres inseguros y agrupa guiones bajos repetidos. El contador de carpetas utiliza el nombre original del elemento; el de adjuntos utiliza el nombre base saneado.
Los nombres repetidos pueden recibir sufijos numéricos, por lo que Personal_1 o invoice_1.pdf no indican automáticamente un error. Estos contadores no prueban que no haya colisiones: distintos nombres originales pueden sanearse a la misma carpeta, y un sufijo generado puede coincidir con otro nombre existente. Audita los bytes reales y una referencia independiente; no generalices informes históricos a todas las versiones ni afirmes que los sufijos eliminan todas las colisiones.
3. Ejecuta la auditoría sobre una copia
Guarda todo el código de biblioteca estándar siguiente como bitwarden_zip_audit.py junto a una copia estable. Ejecuta python3 bitwarden_zip_audit.py bitwarden_export.zip --output bitwarden-audit.json. No extrae miembros ni modifica el ZIP. Rechaza una fuente cuyo componente final sea un enlace simbólico y no sobrescribe informes. Los informes nuevos usan modo 0600 en POSIX; en Windows el acceso depende de sus permisos de archivo, que esta prueba no verificó.
Exige un único data.json de nivel superior, decodifica UTF-8 explícitamente, rechaza propiedades JSON repetidas y requiere encrypted=false más una matriz items de objetos. Registra rutas originales inseguras, nombres ZIP duplicados, colisiones por mayúsculas, adjuntos vacíos y correspondencias ambiguas de carpetas. Rechaza miembros cifrados o no regulares y los límites de tamaño y compresión antes de leer su contenido.
Para cada adjunto presente registra ruta exacta, tamaños descomprimido y comprimido, CRC32 y SHA-256; también calcula el hash del ZIP entero. Mantén el informe fuera del archivo, protege sus nombres de elementos y archivos y conserva estables ambas copias de entrada.
Los límites son 50,000 miembros ZIP, 20 GiB de expansión total declarada, 4 GiB por miembro, 100 MiB de data.json, 16 MiB de manifiesto y 50,000 filas de referencia. Rechaza un ratio superior a 1,000 en miembros mayores de 1 MiB. Los flujos de adjuntos tienen un máximo de lectura de 4 GiB. El análisis del directorio ZIP y el hash de todo el ZIP comprimido ocurren antes de algunos límites: no es un entorno aislado para archivos hostiles ni una garantía universal de CPU o memoria.
```python #!/usr/bin/env python3 """Read-only structural and integrity audit for Bitwarden ZIP exports. The report deliberately does not claim attachment completeness: current Bitwarden data.json exports do not include attachment metadata. It inventories what is in the archive, validates bounded structure, and highlights conditions that require a separate pre-export manifest or manual verification. """ from __future__ import annotations import argparse import hashlib import json import os import re import stat import sys import zipfile import zlib from collections import Counter, defaultdict from pathlib import Path, PurePosixPath MAX_MEMBERS = 50_000 MAX_UNCOMPRESSED = 20 * 1024**3 MAX_MEMBER = 4 * 1024**3 MAX_RATIO = 1_000 class AuditError(Exception): pass def sha256_stream(stream, limit=None) -> str: digest = hashlib.sha256() count = 0 for chunk in iter(lambda: stream.read(1024 * 1024), b""): count += len(chunk) if limit is not None and count > limit: raise AuditError("member stream exceeds the selected read limit") digest.update(chunk) return digest.hexdigest() def safe_member_name(name: str) -> bool: if not name or "\\" in name or "\x00" in name or name.startswith("/") or re.match(r"^[A-Za-z]:", name): return False return all(part not in ("", ".", "..") for part in name.split("/")) def unique_object(pairs): value = {} for key, item in pairs: if key in value: raise AuditError("duplicate JSON property is outside the audit scope") value[key] = item return value def sanitize_cipher_name(name: str) -> str: # Mirrors the current Bitwarden client ZIP implementation. return re.sub(r"_+", "_", re.sub(r'[/\\><:"|?*]', "_", name)) def refuse_unsafe_path(path: Path, role: str, must_exist: bool) -> Path: if path.is_symlink(): raise AuditError(f"{role} must not be a symlink: {path}") resolved = path.resolve(strict=must_exist) if must_exist and not resolved.is_file(): raise AuditError(f"{role} is not a regular file: {path}") if must_exist and not stat.S_ISREG(resolved.stat().st_mode): raise AuditError(f"{role} is not a regular file: {path}") return resolved def load_expected_manifest(path: Path) -> dict[str, dict]: path = refuse_unsafe_path(path, "expected manifest", True) if path.stat().st_size > 16 * 1024**2: raise AuditError("expected manifest exceeds the 16 MiB limit") try: document = json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AuditError(f"expected manifest is not valid UTF-8 JSON: {exc}") from exc if not isinstance(document, dict) or type(document.get("schemaVersion")) is not int or document.get("schemaVersion") != 1: raise AuditError("unsupported expected manifest: schemaVersion must be 1") rows = document.get("attachments") if not isinstance(rows, list): raise AuditError("unsupported expected manifest: attachments must be an array") if len(rows) > MAX_MEMBERS: raise AuditError("expected manifest has too many rows") expected = {} for index, row in enumerate(rows): if not isinstance(row, dict) or not isinstance(row.get("path"), str): raise AuditError(f"expected manifest row {index} requires a string path") member = row["path"] if not member.startswith("attachments/") or not safe_member_name(member): raise AuditError(f"expected manifest row {index} has an unsafe attachment path") if member in expected: raise AuditError(f"expected manifest repeats path: {member}") if "size" in row and (type(row["size"]) is not int or row["size"] < 0): raise AuditError(f"expected manifest row {index} has an invalid size") if "sha256" in row and not re.fullmatch(r"[0-9a-f]{64}", str(row["sha256"])): raise AuditError(f"expected manifest row {index} has an invalid sha256") expected[member] = row return expected def audit_zip(source: Path, expected_manifest: Path | None = None) -> dict: source = refuse_unsafe_path(source, "source ZIP", True) if not zipfile.is_zipfile(source): raise AuditError("source is not a readable ZIP archive") with source.open("rb") as archive_stream: archive_hash = sha256_stream(archive_stream) findings: list[dict] = [] members: list[dict] = [] with zipfile.ZipFile(source) as zf: infos = zf.infolist() if len(infos) > MAX_MEMBERS: raise AuditError(f"archive has {len(infos)} members; limit is {MAX_MEMBERS}") raw_counts = Counter(info.filename for info in infos) duplicates = sorted(name for name, count in raw_counts.items() if count > 1) if duplicates: findings.append({"severity": "error", "code": "duplicate-member-name", "members": duplicates}) casefold_groups: dict[str, list[str]] = defaultdict(list) total_uncompressed = 0 data_infos = [] attachment_files = [] for info in infos: name = info.filename casefold_groups[name.casefold()].append(name) if not safe_member_name(name[:-1] if info.is_dir() else name): findings.append({"severity": "error", "code": "unsafe-member-path", "member": name}) if info.flag_bits & 0x1: raise AuditError("encrypted ZIP members are outside scope") mode = stat.S_IFMT(info.external_attr >> 16) if not info.is_dir() and mode not in (0, stat.S_IFREG): raise AuditError("non-regular ZIP member is outside scope") total_uncompressed += info.file_size if info.file_size > MAX_MEMBER: raise AuditError("member exceeds the 4 GiB limit") ratio = info.file_size / max(1, info.compress_size) if ratio > MAX_RATIO and info.file_size > 1024 * 1024: raise AuditError("member exceeds the selected compression-ratio scope") if name == "data.json": data_infos.append(info) if name.startswith("attachments/") and not info.is_dir(): attachment_files.append(info) if total_uncompressed > MAX_UNCOMPRESSED: raise AuditError(f"archive expands to {total_uncompressed} bytes; limit is {MAX_UNCOMPRESSED}") collisions = [sorted(set(names)) for names in casefold_groups.values() if len(set(names)) > 1] if collisions: findings.append({"severity": "warning", "code": "casefold-name-collision", "groups": collisions}) if len(data_infos) != 1: raise AuditError(f"expected exactly one top-level data.json; found {len(data_infos)}") data_info = data_infos[0] if data_info.file_size > 100 * 1024**2: raise AuditError("data.json exceeds the 100 MiB audit limit") try: with zf.open(data_info) as stream: raw = stream.read(100 * 1024**2 + 1) if len(raw) > 100 * 1024**2: raise AuditError("data.json stream exceeds the 100 MiB limit") document = json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object) except (UnicodeDecodeError, json.JSONDecodeError) as exc: raise AuditError(f"data.json is not valid UTF-8 JSON: {exc}") from exc if not isinstance(document, dict) or not isinstance(document.get("items"), list): raise AuditError("unsupported data.json schema: expected an object with an items array") if document.get("encrypted") is not False: raise AuditError("unsupported data.json schema: ZIP audit expects encrypted=false") if any(not isinstance(item, dict) for item in document["items"]): raise AuditError("items must contain objects") item_names = [item.get("name", "") for item in document["items"] if isinstance(item, dict)] expected_folders: dict[str, list[str]] = defaultdict(list) for item_name in item_names: if isinstance(item_name, str): expected_folders[sanitize_cipher_name(item_name)].append(item_name) folders: dict[str, list[zipfile.ZipInfo]] = defaultdict(list) for info in attachment_files: parts = PurePosixPath(info.filename).parts if len(parts) < 3: findings.append({"severity": "warning", "code": "unexpected-attachment-path", "member": info.filename}) continue folders[parts[1]].append(info) for folder in sorted(folders): base = re.sub(r"_\d+$", "", folder) candidates = expected_folders.get(folder, []) + (expected_folders.get(base, []) if base != folder else []) possible = sorted(set(candidates)) if not possible: findings.append({"severity": "warning", "code": "unmapped-attachment-folder", "folder": folder}) elif len(candidates) > 1: findings.append({"severity": "warning", "code": "ambiguous-attachment-folder", "folder": folder, "possibleItems": possible}) for info in attachment_files: with zf.open(info) as stream: member_hash = sha256_stream(stream, MAX_MEMBER) entry = { "path": info.filename, "size": info.file_size, "compressedSize": info.compress_size, "crc32": f"{info.CRC:08x}", "sha256": member_hash, } members.append(entry) if info.file_size == 0: findings.append({"severity": "warning", "code": "zero-byte-attachment", "member": info.filename}) baseline = None if expected_manifest is not None: expected = load_expected_manifest(expected_manifest) actual = {entry["path"]: entry for entry in members} missing = sorted(set(expected) - set(actual)) unexpected = sorted(set(actual) - set(expected)) mismatches = [] for path in sorted(set(expected) & set(actual)): wanted, present = expected[path], actual[path] if "size" in wanted and wanted["size"] != present["size"]: mismatches.append({"path": path, "field": "size", "expected": wanted["size"], "actual": present["size"]}) if "sha256" in wanted and wanted["sha256"] != present["sha256"]: mismatches.append({"path": path, "field": "sha256", "expected": wanted["sha256"], "actual": present["sha256"]}) if missing: findings.append({"severity": "error", "code": "baseline-missing-attachments", "members": missing}) if unexpected: findings.append({"severity": "warning", "code": "baseline-unexpected-attachments", "members": unexpected}) if mismatches: findings.append({"severity": "error", "code": "baseline-mismatch", "mismatches": mismatches}) baseline = { "name": expected_manifest.name, "entries": len(expected), "matched": len(set(expected) & set(actual)) - len({m["path"] for m in mismatches}), "missing": len(missing), "unexpected": len(unexpected), "mismatches": len(mismatches), "provenanceWarning": "This comparison proves equality only to the supplied manifest, not to live Bitwarden server state.", } findings.append({ "severity": "info", "code": "completeness-not-provable-from-zip-alone", "detail": "Current data.json exports do not carry attachment metadata. Compare this inventory with a separately captured pre-export manifest before any destructive action.", }) status = "fail" if any(f["severity"] == "error" for f in findings) else "review" return { "schemaVersion": 1, "status": status, "source": {"name": source.name, "bytes": source.stat().st_size, "sha256": archive_hash}, "scope": { "proves": ["ZIP readability", "bounded member structure", "attachment member byte hashes", "data.json parseability"], "doesNotProve": ["every server-side attachment was exported", "organization-vault coverage", "restore success"], }, "summary": {"itemsInDataJson": len(item_names), "attachmentFiles": len(members), "attachmentFolders": len(folders)}, "baseline": baseline, "findings": findings, "attachments": sorted(members, key=lambda item: item["path"]), } def write_report(report: dict, destination: Path) -> None: if destination.exists() or destination.is_symlink(): raise AuditError(f"refusing to overwrite output: {destination}") parent = destination.parent.resolve(strict=True) if not parent.is_dir(): raise AuditError(f"output parent is not a directory: {parent}") payload = (json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode() flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL fd = os.open(destination, flags, 0o600) with os.fdopen(fd, "wb") as stream: stream.write(payload) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("zip", type=Path) parser.add_argument("--output", required=True, type=Path) parser.add_argument("--expected-manifest", type=Path, help="optional schemaVersion=1 JSON baseline of attachment paths, sizes and/or SHA-256 values") args = parser.parse_args(argv) try: source = refuse_unsafe_path(args.zip, "source ZIP", True) output = refuse_unsafe_path(args.output, "output", False) if source == output: raise AuditError("output must not replace the source ZIP") manifest = args.expected_manifest if manifest is not None: manifest = refuse_unsafe_path(manifest, "expected manifest", True) write_report(audit_zip(source, manifest), output) except (AuditError, OSError, ValueError, RuntimeError, EOFError, zlib.error, zipfile.BadZipFile, NotImplementedError) as exc: print(f"audit failed: {exc}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main()) ```
El comando sale con 0 si escribe un informe, incluso si status es fail. Lee status y todos los finding: 0 no autoriza borrar. Sale con 2 cuando no puede completar la auditoría o el informe. Conserva la fuente si falta el informe, se interrumpe el proceso o el resultado es incierto.
4. Interpreta el estado correctamente
fail significa que al menos un error estructural o de referencia necesita investigación. No borres la fuente ni la abandones al migrar. Las rutas originales duplicadas, rutas inseguras, miembros cifrados en este flujo de ZIP en texto plano, miembros de referencia ausentes y diferencias de tamaño o hash son motivos para detenerse.
review es el resultado normal si la estructura se puede leer. Las advertencias requieren juicio: un archivo vacío puede ser legítimo, pero debes abrir una copia segura; las colisiones por mayúsculas pueden comportarse de modo distinto en Windows, macOS y Linux. Una carpeta sin correspondencia o ambigua puede derivarse del saneamiento o de nombres repetidos; un miembro inesperado puede ser nuevo o indicar una referencia antigua.
El script no vuelca objetos completos de data.json ni sus campos de credenciales de acceso y notas. Los informes sí contienen rutas de adjuntos y a veces nombres de elementos, que pueden revelar información privada o secretos. Protege cada informe como la exportación.
5. Añade una referencia mientras conservas la fuente
La comparación sin conexión más sólida usa un manifiesto separado creado antes de borrar. Su formato es schemaVersion 1 y una matriz attachments. Cada fila tiene un path bajo attachments/ y puede incluir size y SHA-256. Las rutas deben coincidir exactamente con los nombres del archivo. Crea la referencia desde una exportación anterior verificada, un inventario manual revisado o una fuente local cuya procedencia puedas explicar.
Ejecuta python3 bitwarden_zip_audit.py bitwarden_export.zip --expected-manifest expected-attachments.json --output bitwarden-audit.json. El informe enumera rutas ausentes e inesperadas, diferencias de tamaño y de hash. Una coincidencia completa solo prueba igualdad con ese manifiesto. Si era incompleto o se creó después de perder datos, la comparación hereda esa limitación.
No incluyas credenciales, claves de sesión ni campos secretos en el manifiesto. La referencia mínima útil es la ruta exacta; tamaño y SHA-256 ayudan a detectar reemplazos accidentales. Si no puedes construir una referencia fiable, conserva la fuente y comprueba manualmente adjuntos importantes, en vez de interpretar review como completo.
6. Considera lo que el ZIP no puede cubrir
La documentación oficial dice que la exportación individual contiene datos de la bóveda individual. Una persona autorizada debe exportar por separado los datos de la organización. No supongas que incluye papelera ni Sends. Registra estas exclusiones en la lista de migración aunque la auditoría individual no tenga errores.
La respuesta ZIP inspeccionada también puede informar al cliente cuántos adjuntos se omitieron por errores de descarga o descifrado. Esa señal de ejecución no se guarda como inventario esperado dentro de data.json. Si el cliente mostró una advertencia, conserva una captura o una nota del registro y detente. El ZIP sin conexión no reconstruye un archivo que nunca se escribió.
Un archivo legible no es una prueba de restauración. Antes de una acción destructiva, considera importar en un entorno de prueba desechable y autorizado solo si las instrucciones oficiales actuales admiten ese flujo y entiendes la exposición del texto plano durante el proceso. En otro caso, conserva al menos dos copias cifradas en medios distintos y mantén la cuenta original hasta verificar la migración de forma independiente.
7. Usa una lista para avanzar o detenerte
No avances sin confirmar que existe la exportación, se registró su hash, data.json se analiza, no hay findings de error, cada advertencia tiene explicación escrita, los adjuntos importantes abren desde una copia segura, existe un plan separado para la organización, se entienden las exclusiones de papelera y Sends y la referencia independiente no tiene miembros ausentes o discrepantes.
Aunque todo se cumpla, conserva la fuente hasta comprobar el destino en uso normal. Si debes borrar la cuenta por motivos legales o de seguridad, guarda el informe, los hashes y la decisión. La conclusión es limitada: verificaste los bytes que tienes y, si la usaste, su igualdad con una referencia identificada. No demostraste desde el ZIP un inventario desconocido del servidor.
8. Qué establecen realmente las pruebas
El script final de 14,377 bytes, SHA-256 6108379a2377560f2a0e52b5e3a9cd9d4120a140b79fabc7a3b747ae188708aa, superó 15 pruebas unitarias sintéticas locales en Python 3/Linux. Cubren inventarios válidos y vacíos, JSON ausente o malformado, duplicados y colisiones por mayúsculas, nombres saneados ambiguos, referencia con ausentes/extra/discrepancias y coincidencia exacta, rechazo de enlaces y sobrescritura, componentes de ruta inseguros, UTF-16 y claves duplicadas, y campos booleanos de referencia.
Las condiciones de límite de tamaño y compresión se ejercitaron con límites pequeños o datos sintéticos comprimibles, no con archivos reales de varios GiB. Las entradas quedaron idénticas en la prueba de coincidencia exacta. No se probaron una bóveda Bitwarden real, descarga del cliente, exportación de organización, importación al destino ni permisos Windows. Es una ayuda local de inventario, no una certificación oficial de integridad completa de Bitwarden.
Preguntas frecuentes
¿review significa que se exportaron todos los adjuntos? No. Significa que el archivo admitido pudo inventariarse sin un finding de error; el conjunto esperado del servidor sigue siendo desconocido sin evidencia separada.
¿Los sufijos numéricos garantizan que no se perdieron archivos? No. Los contadores inspeccionados manejan nombres repetidos, pero no son una prueba general contra colisiones. El ZIP no muestra un adjunto omitido antes de escribirse.
¿Una referencia coincidente prueba la totalidad actual del servidor? No. Solo prueba igualdad con las rutas del manifiesto y sus tamaños o hashes, si los contiene. Registra su procedencia.
¿Puedo usarlo con una exportación JSON cifrada? No. Este flujo espera data.json de un ZIP en texto plano con encrypted=false. No descifra exportaciones restringidas ni pide tu contraseña.
¿Puedo borrar la bóveda después del código 0? No. 0 significa que se escribió un informe. Léelo, resuelve los hallazgos, verifica el destino y conserva la fuente hasta justificar tu decisión separada de eliminación.
