Technology · PRACTICAL GUIDE
Bitwarden export says complete? Audit ZIP attachments before deleting the vault
A local Bitwarden ZIP audit inventories and hashes the files that are present. The archive cannot prove by itself that every server-side attachment was exported. Use this offline audit to catch structural problems, compare with a separately saved baseline and decide when deletion or migration must remain on hold.

Do not delete a Bitwarden account, remove an organization vault, or discard the source device merely because the app displayed a success message. First confirm that the ZIP exists, make a working copy, and audit the copy offline. The safest result is not a green light to delete; it is a documented inventory that you can compare with an independent list of what you expected.
The inspected client code writes data.json and an attachments/ tree, but its exported JSON item model does not carry attachment metadata. A local audit can inventory and hash the members that are present. The ZIP cannot, on its own, establish that a missing server-side attachment was expected.
1. Freeze the destructive step
Keep the source vault and account intact. Copy the downloaded ZIP to a local working directory, disconnect cloud-sync tools from that directory if practical, and never upload the archive to an online ZIP checker. An unencrypted Bitwarden export can contain passwords, notes, identity data and attachment contents. Treat both data.json and every report derived from it as sensitive.
Confirm the file exists and record its size before doing anything else. If the application reported success but no file was written, stop: there is nothing to audit. Export again through an official client path, or resolve the client problem, while the source remains available. A success toast is not evidence that a usable file reached disk.
2. Know what the current ZIP writer does
At the inspected Bitwarden clients commit df9641824436b65207f393d35b8d07225129797b, the individual ZIP writer adds data.json and attachments/, and excludes deleted and organization-owned items when collecting attachments. It replaces unsafe name characters and collapses repeated underscores. Its item-folder counter uses the original item name; its attachment counter uses the sanitized base filename.
Repeated names can receive numeric suffixes, so Personal_1 or invoice_1.pdf is not automatically an error. These counters are not proof of collision-free output: different original item names can sanitize to the same folder, and a generated suffix can overlap another existing filename. Audit actual bytes and an independent baseline; do not claim that all historical collision reports apply to every release or that suffixing eliminates every collision.
3. Run the audit on a copy
Save the complete standard-library code below as bitwarden_zip_audit.py beside a stable working copy. Run python3 bitwarden_zip_audit.py bitwarden_export.zip --output bitwarden-audit.json. It does not extract members or modify the ZIP. It rejects a final-component symlink source and refuses report overwrite. New reports use mode 0600 on POSIX; Windows access depends on its file permissions, which this test did not verify.
The audit requires one top-level data.json, explicitly decodes UTF-8, rejects repeated JSON property names, and requires encrypted=false plus an array of item objects. It records unsafe raw paths, duplicate ZIP names, case-fold collisions, zero-byte attachments and ambiguous item-folder mapping; it refuses unsupported encrypted or non-regular members and refuses the size and compression limits before reading their content.
For each present attachment, it records exact path, uncompressed and compressed sizes, CRC32 and SHA-256; it also hashes the whole source ZIP. Keep the report outside the archive, protect its item and file names, and keep both input copies stable while checking.
Limits are 50,000 ZIP members, 20 GiB declared total expansion, 4 GiB per member, 100 MiB data.json, 16 MiB baseline and 50,000 baseline rows. A compression ratio over 1,000 is refused for members larger than 1 MiB. Attachment streams also have a 4 GiB read cap. Directory parsing and hashing the entire compressed ZIP happen before some limits: this is not a hostile-archive sandbox or a universal CPU/memory bound.
```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()) ```
The command exits 0 when it writes a report, even if the report's status is fail. Inspect status and every finding; 0 does not approve deletion. Exit 2 means it could not complete the audit/report. Keep the source when the report is absent, the process is interrupted or any result is unclear.
4. Read the status correctly
A fail status means at least one structural or baseline error needs investigation. Do not delete or migrate away from the source. Duplicate raw member paths, unsafe paths, encrypted members in this expected plaintext ZIP workflow, a missing baseline member, or a size/hash mismatch are hard stops.
A review status is normal when the archive is structurally readable. Warnings still require judgment: zero-byte files may be legitimate but should be opened from a safe copy; case-fold collisions can behave differently on Windows, macOS and Linux; an unmapped or ambiguous folder may result from sanitized item names or duplicate item names; an unexpected member may represent a new attachment or a stale baseline.
The script does not dump complete data.json objects or their login credential and note fields. Reports do include attachment paths and sometimes item names, which can themselves reveal private information or secrets. Protect every report like the export.
5. Add a baseline when the source is still available
The strongest offline comparison uses a separate manifest created before deletion. The manifest format is intentionally simple: schemaVersion 1 and an attachments array. Each row has a path under attachments/ and can include size and SHA-256. Paths must match the archive names exactly. Create this baseline from a previous known-good export, a carefully reviewed manual inventory, or another locally captured source whose provenance you can explain.
Run: python3 bitwarden_zip_audit.py bitwarden_export.zip --expected-manifest expected-attachments.json --output bitwarden-audit.json. The report then lists missing paths, unexpected paths, size differences and hash differences. A full match proves equality to that supplied baseline only. If the baseline was incomplete or was produced after data had already disappeared, the comparison inherits that limitation.
Do not put vault credentials, session keys or raw secret fields in the manifest. The minimum useful baseline is the exact attachment path; size and SHA-256 make accidental replacement easier to detect. If you cannot build a trustworthy baseline, keep the source and perform manual spot checks on high-value attachments instead of interpreting a review result as complete.
6. Account for scope the ZIP cannot cover
Bitwarden's official export guidance says an individual export contains individual-vault data. Organization data must be exported separately by an authorized organization member. Trash and Sends have their own limits and should not be assumed present. Record these exclusions in the migration checklist even if the individual ZIP audit is clean.
The current ZIP response can also report an attachment skip count to the client when download or decryption of an attachment fails. That runtime signal is not stored as an expected-attachment ledger inside data.json. If the client displayed a skipped-item warning, preserve a screenshot or log note and treat it as a stop condition. The offline archive cannot reconstruct a file that was never written.
A readable archive is not a restore test. Before destructive action, import into a disposable, authorized test environment only if Bitwarden's current official instructions support that workflow and you understand that plaintext exports are exposed during the process. Otherwise keep at least two encrypted copies on separate media and retain the source account until the migration is independently verified.
7. Use a go/no-go checklist
Proceed no further unless: the export file exists; its ZIP hash is recorded; data.json parses; the audit has no error findings; every warning has a written explanation; important attachments open from a safe copy; organization data has a separate plan; excluded Trash and Sends are understood; and any independent baseline comparison has no missing or mismatched members.
Even when every box is checked, retain the source long enough to verify the destination in normal use. If the account must be deleted for legal or security reasons, preserve the audit report, archive hashes and the decision record. The honest conclusion is bounded: you verified the bytes you possess and, if used, their equality to a named baseline. You did not prove an unknowable server-side inventory from the ZIP alone.
8. What the tests actually establish
The final 14,377-byte script, SHA-256 6108379a2377560f2a0e52b5e3a9cd9d4120a140b79fabc7a3b747ae188708aa, passed 15 local synthetic unit tests on Python 3/Linux. They cover valid and empty inventories, missing or malformed JSON, duplicate names and case-fold collisions, sanitized-name ambiguity, baseline missing/extra/mismatched paths, exact baseline matching, symlink and overwrite refusals, raw unsafe components, UTF-16 and duplicate-key refusals, and boolean baseline fields.
Size and ratio stop conditions were exercised with small limits or compressible synthetic fixtures, not real multi-gigabyte archives. Inputs remained byte-identical in the exact-baseline fixture. No actual Bitwarden vault, client download, organization export, destination import or Windows permission test was performed. The code is a local inventory aid, not an official Bitwarden completeness certificate.
Frequently asked questions
Does review mean every attachment was exported? No. It means the supported archive could be inventoried without an error finding; the server-side expected set remains unknown without separate evidence.
Do numeric suffixes guarantee no files were lost? No. The inspected counters handle repeated names but are not a general collision proof. A ZIP cannot reveal an attachment omitted before it was written.
Does a matching baseline prove current server completeness? No. It proves equality only to the supplied baseline's paths and any sizes or hashes it contains. Record that baseline's provenance.
Can I use this with an encrypted JSON export? No. This workflow expects the plaintext ZIP's data.json with encrypted=false. It does not decrypt restricted exports or request your password.
Can I delete the vault after exit code 0? No. Code 0 means a report was written. Read the report, resolve findings, verify the destination and preserve the source until your separate deletion decision is justified.
