Files
juergen 41385b35e6 Initial commit: Bitwarden export to PDF renderer
Adds the render.py WeasyPrint renderer, the pod-based bitwarden-print.sh
wrapper, a Containerfile, a sample export (JSON + PDF), README and LICENSE.
2026-11-13 14:09:10 +01:00

591 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
"""Render a Bitwarden JSON export as a dense PDF.
Every leaf value in the export is printed: well-known fields first in a
readable order, then every remaining value alphabetically. The PDF starts
with a summary and an index that carries page numbers.
"""
from __future__ import annotations
import argparse
import datetime as dt
import html
import json
import re
import sys
from collections import Counter
from pathlib import Path
TYPE_NAMES = {1: "login", 2: "secure note", 3: "card", 4: "identity", 5: "ssh key"}
# Preferred display order; matched by exact path, prefix or list index.
PRIORITY = [
"type",
"name",
"folderId",
"organizationId",
"collectionIds",
"favorite",
"reprompt",
"login.username",
"login.password",
"login.totp",
"login.passwordRevisionDate",
"login.uris",
"login.fido2Credentials",
"card.cardholderName",
"card.brand",
"card.number",
"card.expMonth",
"card.expYear",
"card.code",
"identity.title",
"identity.firstName",
"identity.middleName",
"identity.lastName",
"identity.address1",
"identity.address2",
"identity.address3",
"identity.city",
"identity.state",
"identity.postalCode",
"identity.country",
"identity.company",
"identity.email",
"identity.phone",
"identity.ssn",
"identity.username",
"identity.passportNumber",
"identity.licenseNumber",
"secureNote",
"sshKey.privateKey",
"sshKey.publicKey",
"sshKey.fingerprint",
"sshKey.keyFingerprint",
"fields",
"notes",
"passwordHistory",
"attachments",
"id",
"creationDate",
"revisionDate",
"deletedDate",
]
SECRET_RE = re.compile(
r"(?:^|\.)(password|totp|code|ssn|pin|cvv|privateKey|passportNumber|licenseNumber|number)$"
)
class Empty:
"""Marks an empty dict/list so it is visible in the output."""
def __init__(self, text: str) -> None:
self.text = text
def esc(value: object) -> str:
return html.escape(str(value), quote=True)
def flatten(obj, prefix: str = "", out=None):
"""Flatten nested JSON into (dotted.path[index], leaf) pairs."""
if out is None:
out = []
if isinstance(obj, dict):
if not obj:
out.append((prefix, Empty("{}")))
return out
for key, value in obj.items():
flatten(value, f"{prefix}.{key}" if prefix else str(key), out)
elif isinstance(obj, list):
if not obj:
out.append((prefix, Empty("[]")))
return out
for index, value in enumerate(obj):
flatten(value, f"{prefix}[{index}]", out)
else:
out.append((prefix, obj))
return out
def natural_key(path: str):
parts = re.split(r"(\d+)", path)
return tuple((1, int(p)) if p.isdigit() else (0, p) for p in parts if p)
def rank(path: str):
priority = len(PRIORITY)
for index, prefix in enumerate(PRIORITY):
if path == prefix or path.startswith(prefix + ".") or path.startswith(prefix + "["):
priority = index
break
return (priority, natural_key(path))
def fmt_value(value):
"""Return (kind, text) so CSS can style empties, numbers and text."""
if isinstance(value, Empty):
return ("empty", value.text)
if isinstance(value, bool):
return ("code", "true" if value else "false")
if value is None:
return ("empty", "null")
if isinstance(value, (int, float)):
return ("code", repr(value) if isinstance(value, float) else str(value))
text = str(value)
if text == "":
return ("empty", '""')
return ("text", text)
def is_secret(path: str) -> bool:
return bool(SECRET_RE.search(re.sub(r"\[\d+\]", "", path)))
def mask_text(text: str) -> str:
return f"{'*' * 10} ({len(text)} chars)"
def type_num(item) -> int | None:
value = item.get("type")
return value if isinstance(value, int) and not isinstance(value, bool) else None
def type_label(item) -> str:
value = type_num(item)
if value is None:
return "unknown"
return TYPE_NAMES.get(value, f"type {value}")
def badge_class(item) -> str:
value = type_num(item)
return f"t-{value}" if value in TYPE_NAMES else "t-other"
def folder_paths(folders) -> dict:
by_id = {
f["id"]: f
for f in folders
if isinstance(f, dict) and isinstance(f.get("id"), str)
}
paths = {}
for fid, folder in by_id.items():
parts = [folder.get("name") or fid]
parent = folder.get("parentId")
seen = {fid}
while isinstance(parent, str) and parent in by_id and parent not in seen:
seen.add(parent)
parts.append(by_id[parent].get("name") or parent)
parent = by_id[parent].get("parentId")
paths[fid] = " / ".join(reversed(parts))
return paths
def sort_items(items, mode: str, folders: dict):
def folder_of(item):
fid = item.get("folderId")
return folders.get(fid, str(fid)) if fid else ""
def name_of(item):
return str(item.get("name") or "").casefold()
if mode == "as-is":
return list(items)
if mode == "name":
key = lambda it: (name_of(it), folder_of(it))
elif mode == "type":
key = lambda it: (f"{type_num(it) or 99:03d}", folder_of(it), name_of(it))
else: # folder
key = lambda it: (folder_of(it), name_of(it))
return sorted(items, key=key)
CSS = """
@page {
size: A4;
margin: 9mm 8mm 11mm 8mm;
@bottom-left {
content: string(title) " - " string(source);
font-family: "DejaVu Sans Mono", monospace;
font-size: 5.8pt;
color: #8a8a8a;
}
@bottom-right {
content: counter(page) " / " counter(pages);
font-family: "DejaVu Sans Mono", monospace;
font-size: 6pt;
color: #8a8a8a;
}
}
html { font-family: "DejaVu Sans", sans-serif; font-size: 7.1pt; color: #111; }
body { margin: 0; }
a { color: inherit; text-decoration: none; }
h1 {
string-set: title content();
font-size: 15pt;
margin: 0 0 0.8mm;
letter-spacing: -0.2pt;
}
h2 {
font-size: 8.4pt;
text-transform: uppercase;
letter-spacing: 0.5pt;
color: #555;
margin: 4.5mm 0 1.2mm;
border-bottom: 0.6pt solid #333;
padding-bottom: 0.5mm;
}
.sub { color: #666; font-size: 6.6pt; margin-bottom: 1mm; }
.sub .src { string-set: source content(); font-family: "DejaVu Sans Mono", monospace; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
.mono, th, td.k { font-family: "DejaVu Sans Mono", monospace; }
table.stats th, table.stats td { text-align: left; padding: 0.35mm 1.5mm 0.35mm 0; vertical-align: top; }
table.stats th { width: 32%; color: #666; font-weight: 400; font-size: 6.4pt; }
table.stats td { overflow-wrap: anywhere; }
table.split { width: 100%; }
table.split td.col { vertical-align: top; padding: 0; width: 50%; }
table.split td.col + td.col { padding-left: 5mm; }
table.grid th {
text-align: left;
font-size: 6.1pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3pt;
color: #666;
border-bottom: 0.5pt solid #999;
padding: 0.5mm 1mm;
}
table.grid td {
font-family: "DejaVu Sans Mono", monospace;
font-size: 6.5pt;
padding: 0.35mm 1mm;
border-bottom: 0.3pt dotted #dcdcdc;
overflow-wrap: anywhere;
vertical-align: top;
}
table.grid tbody tr:nth-child(even) { background: #f6f6f6; }
td.r, th.r { text-align: right; }
.dot {
display: inline-block;
width: 1.4mm;
height: 1.4mm;
border-radius: 0.7mm;
margin-right: 1mm;
background: #777;
}
.t-1 { background: #1f6feb; }
.t-2 { background: #6e7781; }
.t-3 { background: #8250df; }
.t-4 { background: #b35c00; }
.t-5 { background: #1a7f37; }
.t-other { background: #555; }
.item { margin: 0 0 2.4mm; }
.item h3 {
font-size: 7.8pt;
margin: 0 0 0.6mm;
padding: 0.5mm 0 0.5mm;
border-bottom: 0.5pt solid #888;
break-after: avoid;
}
.item h3 .idx {
font-family: "DejaVu Sans Mono", monospace;
color: #999;
margin-right: 1.4mm;
}
.badge {
font-family: "DejaVu Sans Mono", monospace;
font-size: 5.8pt;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.2pt;
color: #fff;
padding: 0.25mm 1mm;
border-radius: 0.6mm;
margin-right: 1.2mm;
}
.item h3 .iname { font-weight: 700; }
.item h3 .meta {
color: #666;
font-size: 6.4pt;
font-weight: 400;
margin-left: 1.6mm;
}
table.kv th {
width: 24%;
text-align: left;
font-family: "DejaVu Sans Mono", monospace;
font-size: 6.1pt;
font-weight: 400;
color: #7a7a7a;
padding: 0.4mm 1.6mm 0.4mm 0;
vertical-align: top;
overflow-wrap: anywhere;
}
table.kv td {
font-family: "DejaVu Sans Mono", monospace;
font-size: 6.9pt;
padding: 0.4mm 0;
vertical-align: top;
white-space: pre-wrap;
overflow-wrap: anywhere;
border-bottom: 0.25pt dotted #e4e4e4;
}
table.kv tr { break-inside: avoid; }
td.empty { color: #a0a0a0; }
td.code { color: #0a5c2b; }
td.mask { color: #8a5a00; }
.summary-note { color: #666; font-size: 6.3pt; margin-top: 1mm; }
"""
PAPER_OVERRIDE = {"a4": "A4", "letter": "letter"}
def build_html(*, data, items, source_name, title, mask, sort_mode, paper):
folders = [f for f in (data.get("folders") or []) if isinstance(f, dict)]
fpaths = folder_paths(folders)
collections = [c for c in (data.get("collections") or []) if isinstance(c, dict)]
coll_names = {
c.get("id"): (c.get("name") or c.get("id")) for c in collections if c.get("id")
}
items = sort_items(items, sort_mode, fpaths)
leaves = sum(len(flatten(item)) for item in items)
type_counts = Counter(type_label(item) for item in items)
notes_count = sum(1 for item in items if item.get("notes"))
field_count = sum(
len(item.get("fields") or [])
for item in items
if isinstance(item.get("fields"), list)
)
attach_count = sum(
len(item.get("attachments") or [])
for item in items
if isinstance(item.get("attachments"), list)
)
history_count = sum(
len(item.get("passwordHistory") or [])
for item in items
if isinstance(item.get("passwordHistory"), list)
)
folder_counts = Counter(
fpaths.get(item.get("folderId"), str(item.get("folderId")))
if item.get("folderId")
else "(no folder)"
for item in items
)
def date_range(key):
values = sorted(
str(item[key]) for item in items if isinstance(item.get(key), str) and item[key]
)
return f"{values[0]} .. {values[-1]}" if values else ""
def row(label, value, klass=""):
cls = f' class="{klass}"' if klass else ""
return f"<tr><th>{esc(label)}</th><td{cls}>{esc(value)}</td></tr>\n"
meta_rows = [
row("source", source_name),
row("generated", dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")),
row("items", len(items)),
row("folders", len(folders)),
]
if collections:
meta_rows.append(row("collections", len(collections)))
meta_rows.extend(
[
row("values printed", leaves),
row("items with notes", notes_count),
row("custom fields", field_count),
row("attachments", attach_count),
row("password history", history_count),
row("created range", date_range("creationDate")),
row("revised range", date_range("revisionDate")),
]
)
for key, value in data.items():
if key in {"items", "folders", "collections"}:
continue
kind, text = fmt_value(value)
meta_rows.append(row(f"export.{key}", text, "empty" if kind == "empty" else ""))
type_rows = "".join(
f'<tr><td><span class="dot {badge_class({"type": next((n for n, t in TYPE_NAMES.items() if t == label), None)})}"></span>{esc(label)}</td>'
f'<td class="r">{count}</td></tr>\n'
for label, count in sorted(type_counts.items(), key=lambda kv: (-kv[1], kv[0]))
)
folder_rows = "".join(
f"<tr><td>{esc(name)}</td><td class=\"r\">{count}</td></tr>\n"
for name, count in sorted(folder_counts.items(), key=lambda kv: kv[0].casefold())
)
collection_rows = ""
if collections:
collection_rows = "".join(
f"<tr><td>{esc(c.get('name') or c.get('id') or '')}</td>"
f"<td>{esc(c.get('organizationId') or '')}</td>"
f"<td>{esc(c.get('externalId') or '')}</td>"
f"<td>{esc(c.get('readOnly'))}</td></tr>\n"
for c in collections
)
index_rows = []
for index, item in enumerate(items, 1):
fid = item.get("folderId")
folder = fpaths.get(fid, str(fid)) if fid else ""
login = item.get("login") if isinstance(item.get("login"), dict) else {}
username = login.get("username") or ""
index_rows.append(
f'<tr><td class="r">{index}</td>'
f'<td><span class="dot {badge_class(item)}"></span>{esc(type_label(item))}</td>'
f'<td><a href="#item-{index}">{esc(item.get("name") or "(unnamed)")}</a></td>'
f"<td>{esc(folder)}</td><td>{esc(username)}</td>"
f'<td class="r"><a class="pageref" href="#item-{index}"></a></td></tr>\n'
)
item_html = []
for index, item in enumerate(items, 1):
header_rows = []
fid = item.get("folderId")
folder = fpaths.get(fid, str(fid)) if fid else ""
if fid:
header_rows.append(row("folder", folder))
cids = item.get("collectionIds")
if isinstance(cids, list) and cids:
header_rows.append(
row("collections", ", ".join(str(coll_names.get(cid, cid)) for cid in cids))
)
for path, value in sorted(flatten(item), key=lambda kv: rank(kv[0])):
kind, text = fmt_value(value)
if mask and kind == "text" and is_secret(path):
kind, text = "mask", mask_text(text)
header_rows.append(row(path, text, kind))
meta_bits = []
if folder:
meta_bits.append(folder)
if isinstance(cids, list) and cids:
meta_bits.append(", ".join(str(coll_names.get(cid, cid)) for cid in cids))
meta = (
f'<span class="meta">{esc(" · ".join(meta_bits))}</span>' if meta_bits else ""
)
item_html.append(
f'<section class="item">\n'
f'<h3 id="item-{index}"><span class="idx">{index}</span>'
f'<span class="badge {badge_class(item)}">{esc(type_label(item))}</span>'
f'<span class="iname">{esc(item.get("name") or "(unnamed)")}</span>{meta}</h3>\n'
f'<table class="kv">\n{"".join(header_rows)}</table>\n'
f"</section>\n"
)
paper_css = PAPER_OVERRIDE.get(paper, "A4")
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>{esc(title)}</title>
<style>{CSS}
@page {{ size: {paper_css}; }}
.pageref::after {{ content: target-counter(attr(href), page); }}
</style>
</head>
<body>
<h1>{esc(title)}</h1>
<p class="sub"><span class="src">{esc(source_name)}</span> &middot; {len(items)} items &middot; {leaves} values &middot; generated {dt.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M %Z")}</p>
<h2>Summary</h2>
<table class="split"><tr>
<td class="col"><table class="stats">{"".join(meta_rows)}</table></td>
<td class="col">
<table class="grid"><thead><tr><th>type</th><th class="r">items</th></tr></thead><tbody>{type_rows}</tbody></table>
<table class="grid" style="margin-top:3mm"><thead><tr><th>folder</th><th class="r">items</th></tr></thead><tbody>{folder_rows}</tbody></table>
</td></tr></table>
{f'<h2>Collections</h2><table class="grid"><thead><tr><th>name</th><th>organizationId</th><th>externalId</th><th>readOnly</th></tr></thead><tbody>{collection_rows}</tbody></table>' if collections else ''}
<h2>Index</h2>
<table class="grid"><thead><tr><th class="r" style="width:6%">#</th><th style="width:14%">type</th><th>name</th><th style="width:20%">folder</th><th style="width:16%">username</th><th class="r" style="width:6%">page</th></tr></thead>
<tbody>{"".join(index_rows)}</tbody></table>
<h2>Items</h2>
{"".join(item_html)}
</body>
</html>
"""
def load_export(path: Path):
with path.open("r", encoding="utf-8") as handle:
data = json.load(handle)
if not isinstance(data, dict):
raise SystemExit("error: export root must be a JSON object")
if data.get("encrypted") is True:
raise SystemExit(
"error: this is an encrypted export and cannot be read.\n"
" Re-export from Bitwarden with 'Export as .json' and no file password."
)
items = data.get("items") or []
if not isinstance(items, list):
raise SystemExit("error: 'items' is not a list; is this a Bitwarden export?")
items = [item for item in items if isinstance(item, dict)]
return data, items
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description="Render a Bitwarden JSON export as a dense PDF."
)
parser.add_argument("input", type=Path, help="Bitwarden .json export")
parser.add_argument("-o", "--output", type=Path, help="output PDF (default: input.pdf)")
parser.add_argument("--title", help="document title")
parser.add_argument(
"--sort",
choices=["folder", "name", "type", "as-is"],
default="folder",
help="item order (default: folder)",
)
parser.add_argument(
"--mask-secrets",
action="store_true",
help="mask passwords, TOTP, card numbers/codes, SSN and private keys",
)
parser.add_argument("--paper", choices=["a4", "letter"], default="a4")
parser.add_argument("--html-out", type=Path, help="also write the HTML source")
args = parser.parse_args(argv)
if not args.input.is_file():
raise SystemExit(f"error: no such file: {args.input}")
data, items = load_export(args.input)
title = args.title or "Bitwarden export"
document = build_html(
data=data,
items=items,
source_name=args.input.name,
title=title,
mask=args.mask_secrets,
sort_mode=args.sort,
paper=args.paper,
)
if args.html_out:
args.html_out.parent.mkdir(parents=True, exist_ok=True)
args.html_out.write_text(document, encoding="utf-8")
output = args.output or args.input.with_suffix(".pdf")
output.parent.mkdir(parents=True, exist_ok=True)
from weasyprint import HTML
HTML(string=document, base_url=str(args.input.parent)).write_pdf(str(output))
print(f"wrote {output} ({len(items)} items)")
return 0
if __name__ == "__main__":
sys.exit(main())