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.
This commit is contained in:
commit
41385b35e6
8 files changed
+1041
No files matched your search
@@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
build/
|
||||
dist/
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM docker.io/python:3.12-slim-bookworm
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
# Runtime libraries required by WeasyPrint's Pango/HarfBuzz/Cairo stack.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
libpango-1.0-0 \
|
||||
libpangoft2-1.0-0 \
|
||||
libharfbuzz0b \
|
||||
libfontconfig1 \
|
||||
libcairo2 \
|
||||
libgdk-pixbuf-2.0-0 \
|
||||
libffi8 \
|
||||
libjpeg62-turbo \
|
||||
libopenjp2-7 \
|
||||
shared-mime-info \
|
||||
fonts-dejavu-core \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pip install "weasyprint==62.3" "pydyf==0.10.0"
|
||||
|
||||
WORKDIR /app
|
||||
COPY render.py /app/render.py
|
||||
|
||||
ENTRYPOINT ["python3", "/app/render.py"]
|
||||
CMD ["--help"]
|
||||
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 juergen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,117 @@
|
||||
# bitwarden-print
|
||||
|
||||
Render a Bitwarden `.json` export as a dense PDF: a summary, a page-numbered
|
||||
index, and one compact block per vault item that prints **every** value in the
|
||||
export — known fields first (username, password, TOTP, card, identity, SSH key,
|
||||
custom fields, notes, history, attachments), then everything else
|
||||
alphabetically, including unknown/extra keys and empty objects/lists.
|
||||
|
||||
Everything runs in a throwaway Podman pod on this machine; the vault never
|
||||
leaves it.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Podman (tested with 6.x)
|
||||
- On macOS: `podman machine start` (the default machine is used)
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
./bitwarden-print.sh ~/Downloads/bitwarden_export_20261113.json
|
||||
# -> ~/Downloads/bitwarden_export_20261113.pdf
|
||||
```
|
||||
|
||||
Custom output and options:
|
||||
|
||||
```sh
|
||||
./bitwarden-print.sh vault.json -o ~/Desktop/vault.pdf \
|
||||
--paper letter --sort name --title "Vault 2026-11-13"
|
||||
```
|
||||
|
||||
Mask secrets instead of printing them:
|
||||
|
||||
```sh
|
||||
./bitwarden-print.sh vault.json --mask-secrets
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
| Option | Meaning |
|
||||
| --- | --- |
|
||||
| `-o, --output FILE` | output PDF (default: next to the export, same basename) |
|
||||
| `--sort folder\|name\|type\|as-is` | item order (default: `folder`, then name) |
|
||||
| `--title TEXT` | document title (also the page footer) |
|
||||
| `--paper a4\|letter` | page size (default: `a4`) |
|
||||
| `--mask-secrets` | mask `login.password`, `login.totp`, `card.number`, `card.code`, `identity.ssn`, `sshKey.privateKey`, …; values keep a length hint |
|
||||
|
||||
## What the PDF contains
|
||||
|
||||
1. **Header** — source file, item count, value count, timestamp.
|
||||
2. **Summary** — export metadata, item/type/folder counts, custom field,
|
||||
attachment and password-history totals, created/revised date ranges, plus a
|
||||
collections table if the export has one.
|
||||
3. **Index** — one line per item (type, name, folder, username) with a real
|
||||
page number, clickable in the PDF.
|
||||
4. **Items** — for each item a heading (number, type badge, name, folder) and a
|
||||
two-column table of `json.path` → value. Values wrap, newlines in notes are
|
||||
preserved, and empty values (`null`, `""`, `{}`, `[]`) are shown dimmed so
|
||||
nothing silently disappears.
|
||||
|
||||
Items are not truncated: an export with many entries produces many pages, but
|
||||
each line is only a few millimetres tall.
|
||||
|
||||
## How it runs
|
||||
|
||||
```
|
||||
./bitwarden-print.sh
|
||||
-> podman build -t localhost/bitwarden-print:latest . # Python + WeasyPrint image
|
||||
-> podman pod create --name bitwarden-print-pod
|
||||
-> podman create --pod bitwarden-print-pod ... bitwarden-print-run
|
||||
-> podman cp export.json bitwarden-print-run:/export.json # no bind mounts
|
||||
-> podman start -a bitwarden-print-run # render.py -> /output.pdf
|
||||
-> podman cp bitwarden-print-run:/output.pdf <output>
|
||||
-> pod, container and temp file are removed
|
||||
```
|
||||
|
||||
The input is copied into the container instead of bind-mounted, so arbitrary
|
||||
host paths work (including `/tmp`, which the Podman machine does not share) and
|
||||
no SELinux relabelling is required.
|
||||
|
||||
Manual equivalent:
|
||||
|
||||
```sh
|
||||
podman build -t localhost/bitwarden-print:latest .
|
||||
podman pod create --name bitwarden-print-pod
|
||||
podman create --pod bitwarden-print-pod --name bw localhost/bitwarden-print:latest \
|
||||
/export.json -o /output.pdf
|
||||
podman cp vault.json bw:/export.json
|
||||
podman start -a bw
|
||||
podman cp bw:/output.pdf ./vault.pdf
|
||||
podman rm -f bw && podman pod rm -f bitwarden-print-pod
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `bitwarden-print.sh` — CLI wrapper that builds the image and drives the pod
|
||||
- `Containerfile` — `python:3.12-slim-bookworm` + WeasyPrint (Pango/Cairo) + DejaVu fonts
|
||||
- `render.py` — JSON → HTML → PDF, no data leaves the container
|
||||
- `examples/sample-export.json` — synthetic vault used for testing (all values fake)
|
||||
|
||||
Run the renderer directly on a host that has WeasyPrint installed:
|
||||
|
||||
```sh
|
||||
pip install weasyprint==62.3 pydyf==0.10.0
|
||||
./render.py examples/sample-export.json -o /tmp/sample.pdf --html-out /tmp/sample.html
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- The generated PDF contains your vault in **plaintext** unless you pass
|
||||
`--mask-secrets`. Treat the PDF like the export itself: keep it local, and
|
||||
delete it when you are done.
|
||||
- `--mask-secrets` only masks well-known secret paths. Secrets stored inside
|
||||
custom fields (`fields[i].value`), notes or file names are printed as-is.
|
||||
- Encrypted exports (`"encrypted": true`) are rejected with a hint: re-export
|
||||
from Bitwarden as `.json` without a file password first.
|
||||
- The container is removed after each run, but the image
|
||||
(`localhost/bitwarden-print:latest`) stays cached for the next run.
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render a Bitwarden JSON export to PDF inside a dedicated Podman pod.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||
IMAGE=${IMAGE:-localhost/bitwarden-print:latest}
|
||||
POD=${POD:-bitwarden-print-pod}
|
||||
CONTAINER=${CONTAINER:-bitwarden-print-run}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./bitwarden-print.sh EXPORT.json [-o OUT.pdf] [options]
|
||||
|
||||
Creates a PDF with every value from a Bitwarden JSON export, densely typeset.
|
||||
|
||||
Options:
|
||||
-o, --output FILE output PDF (default: next to the export, same basename)
|
||||
--sort MODE folder (default), name, type or as-is
|
||||
--title TEXT document title
|
||||
--paper SIZE a4 (default) or letter
|
||||
--mask-secrets mask passwords, TOTP, card numbers/codes, SSN, keys
|
||||
-h, --help show this help
|
||||
|
||||
Environment:
|
||||
IMAGE, POD, CONTAINER override image/pod/container names
|
||||
EOF
|
||||
}
|
||||
|
||||
INPUT=""
|
||||
OUTPUT=""
|
||||
render_args=()
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o|--output)
|
||||
[ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; exit 2; }
|
||||
OUTPUT=$2; shift 2 ;;
|
||||
--sort)
|
||||
[ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; exit 2; }
|
||||
render_args+=(--sort "$2"); shift 2 ;;
|
||||
--paper)
|
||||
[ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; exit 2; }
|
||||
render_args+=(--paper "$2"); shift 2 ;;
|
||||
--title)
|
||||
[ $# -ge 2 ] || { echo "error: $1 needs a value" >&2; exit 2; }
|
||||
render_args+=(--title "$2"); shift 2 ;;
|
||||
--mask-secrets)
|
||||
render_args+=(--mask-secrets); shift ;;
|
||||
-h|--help)
|
||||
usage; exit 0 ;;
|
||||
-*)
|
||||
echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*)
|
||||
[ -z "$INPUT" ] || { echo "error: only one export file can be given" >&2; exit 2; }
|
||||
INPUT=$1; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$INPUT" ] || { usage >&2; exit 2; }
|
||||
[ -f "$INPUT" ] || { echo "error: no such file: $INPUT" >&2; exit 1; }
|
||||
|
||||
in_dir=$(cd -- "$(dirname -- "$INPUT")" && pwd)
|
||||
in_name=$(basename -- "$INPUT")
|
||||
|
||||
if [ -z "$OUTPUT" ]; then
|
||||
OUTPUT="$in_dir/${in_name%.json}.pdf"
|
||||
fi
|
||||
out_dir=$(dirname -- "$OUTPUT")
|
||||
mkdir -p -- "$out_dir"
|
||||
out_dir=$(cd -- "$out_dir" && pwd)
|
||||
out_name=$(basename -- "$OUTPUT")
|
||||
|
||||
cleanup() {
|
||||
podman rm -f "$CONTAINER" >/dev/null 2>&1 || true
|
||||
podman pod exists "$POD" >/dev/null 2>&1 && podman pod rm -f "$POD" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "==> building image $IMAGE"
|
||||
podman build -q -t "$IMAGE" "$SCRIPT_DIR"
|
||||
|
||||
echo "==> creating pod $POD"
|
||||
podman pod rm -f "$POD" >/dev/null 2>&1 || true
|
||||
podman pod create --name "$POD" >/dev/null
|
||||
|
||||
# Copy the export in instead of bind-mounting it: works for any host path,
|
||||
# including paths the Podman machine does not share (e.g. /tmp) and hosts
|
||||
# where SELinux relabelling is not allowed.
|
||||
echo "==> rendering $in_name"
|
||||
podman create --pod "$POD" --name "$CONTAINER" \
|
||||
"$IMAGE" "/export.json" -o "/output.pdf" \
|
||||
${render_args[@]+"${render_args[@]}"} >/dev/null
|
||||
podman cp "$INPUT" "$CONTAINER:/export.json"
|
||||
|
||||
status=0
|
||||
podman start -a "$CONTAINER" || status=$?
|
||||
if [ "$status" -ne 0 ]; then
|
||||
echo "error: rendering failed (exit $status)" >&2
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
tmp_pdf=$(mktemp "${TMPDIR:-/tmp}/bitwarden-print.XXXXXX.pdf")
|
||||
if ! podman cp "$CONTAINER:/output.pdf" "$tmp_pdf"; then
|
||||
echo "error: container produced no PDF at /output.pdf" >&2
|
||||
exit 1
|
||||
fi
|
||||
mv -f "$tmp_pdf" "$out_dir/$out_name"
|
||||
|
||||
echo "==> PDF: $out_dir/$out_name"
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"encrypted": false,
|
||||
"folders": [
|
||||
{ "id": "f1b2c3d4-0000-4000-8000-000000000001", "name": "Work" },
|
||||
{ "id": "f1b2c3d4-0000-4000-8000-000000000002", "name": "Personal" },
|
||||
{ "id": "f1b2c3d4-0000-4000-8000-000000000003", "name": "Infrastructure" }
|
||||
],
|
||||
"items": [
|
||||
{
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"organizationId": null,
|
||||
"folderId": "f1b2c3d4-0000-4000-8000-000000000001",
|
||||
"type": 1,
|
||||
"name": "GitHub",
|
||||
"notes": "Recovery codes are in the safe.\n2FA enforced on the org.",
|
||||
"favorite": true,
|
||||
"reprompt": 1,
|
||||
"login": {
|
||||
"uris": [
|
||||
{ "match": null, "uri": "https://github.com/login" },
|
||||
{ "match": 0, "uri": "https://gist.github.com" }
|
||||
],
|
||||
"username": "jdoe@example.com",
|
||||
"password": "correct-horse-battery-staple",
|
||||
"totp": "otpauth://totp/GitHub:jdoe?secret=JBSWY3DPEHPK3PXP",
|
||||
"passwordRevisionDate": "2024-03-11T09:12:44.000Z"
|
||||
},
|
||||
"collectionIds": null,
|
||||
"creationDate": "2019-05-02T14:22:10.000Z",
|
||||
"revisionDate": "2024-03-11T09:12:44.000Z",
|
||||
"deletedDate": null,
|
||||
"fields": [
|
||||
{ "name": "recovery email", "value": "recovery@example.com", "type": 0, "linkedId": null },
|
||||
{ "name": "PIN", "value": "4711", "type": 1, "linkedId": null }
|
||||
],
|
||||
"passwordHistory": [
|
||||
{ "lastUsedDate": "2023-11-01T08:00:00.000Z", "password": "old-secret-1" },
|
||||
{ "lastUsedDate": "2022-06-15T08:00:00.000Z", "password": "old-secret-0" }
|
||||
],
|
||||
"attachments": [
|
||||
{
|
||||
"id": "att-0001",
|
||||
"fileName": "recovery-codes.txt",
|
||||
"size": "482",
|
||||
"sizeName": "482 bytes",
|
||||
"url": "https://vault.example/attachments/att-0001"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "22222222-2222-4222-8222-222222222222",
|
||||
"organizationId": "org-1",
|
||||
"folderId": "f1b2c3d4-0000-4000-8000-000000000001",
|
||||
"type": 1,
|
||||
"name": "Internal wiki",
|
||||
"notes": null,
|
||||
"favorite": false,
|
||||
"reprompt": 0,
|
||||
"login": {
|
||||
"uris": [{ "match": 0, "uri": "https://wiki.internal.example" }],
|
||||
"username": "svc-wiki",
|
||||
"password": "S3cr3t!wiki#2024",
|
||||
"totp": null
|
||||
},
|
||||
"collectionIds": ["col-1"],
|
||||
"creationDate": "2021-01-07T10:00:00.000Z",
|
||||
"revisionDate": "2024-09-30T16:45:00.000Z",
|
||||
"fields": null,
|
||||
"passwordHistory": null,
|
||||
"attachments": null
|
||||
},
|
||||
{
|
||||
"id": "33333333-3333-4333-8333-333333333333",
|
||||
"organizationId": null,
|
||||
"folderId": "f1b2c3d4-0000-4000-8000-000000000002",
|
||||
"type": 3,
|
||||
"name": "Visa Business",
|
||||
"notes": "Expires 2027, travel card.",
|
||||
"favorite": false,
|
||||
"reprompt": 0,
|
||||
"card": {
|
||||
"cardholderName": "Jane Doe",
|
||||
"brand": "Visa",
|
||||
"number": "4111111111111111",
|
||||
"expMonth": "9",
|
||||
"expYear": "2027",
|
||||
"code": "123"
|
||||
},
|
||||
"creationDate": "2020-02-02T12:00:00.000Z",
|
||||
"revisionDate": "2023-04-18T07:30:00.000Z",
|
||||
"fields": []
|
||||
},
|
||||
{
|
||||
"id": "44444444-4444-4444-8444-444444444444",
|
||||
"organizationId": null,
|
||||
"folderId": "f1b2c3d4-0000-4000-8000-000000000002",
|
||||
"type": 4,
|
||||
"name": "Passport",
|
||||
"notes": null,
|
||||
"favorite": false,
|
||||
"reprompt": 0,
|
||||
"identity": {
|
||||
"title": "Dr.",
|
||||
"firstName": "Jane",
|
||||
"middleName": "Q.",
|
||||
"lastName": "Doe",
|
||||
"address1": "1 Example Street",
|
||||
"address2": "Apt 4",
|
||||
"address3": "",
|
||||
"city": "Springfield",
|
||||
"state": "IL",
|
||||
"postalCode": "62704",
|
||||
"country": "United States",
|
||||
"company": "Example GmbH",
|
||||
"email": "jane@example.com",
|
||||
"phone": "+1 555 0100",
|
||||
"ssn": "000-00-0000",
|
||||
"username": "jdoe",
|
||||
"passportNumber": "X1234567",
|
||||
"licenseNumber": "D-998877"
|
||||
},
|
||||
"creationDate": "2018-08-08T08:08:08.000Z",
|
||||
"revisionDate": "2022-12-24T18:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"id": "55555555-5555-4555-8555-555555555555",
|
||||
"organizationId": null,
|
||||
"folderId": "f1b2c3d4-0000-4000-8000-000000000003",
|
||||
"type": 2,
|
||||
"name": "Router recovery phrase",
|
||||
"notes": "word word word word word word word word",
|
||||
"favorite": false,
|
||||
"reprompt": 0,
|
||||
"secureNote": { "type": 0 },
|
||||
"creationDate": "2023-03-03T03:03:03.000Z",
|
||||
"revisionDate": "2023-03-03T03:03:03.000Z",
|
||||
"fields": [
|
||||
{ "name": "admin password", "value": "admin-router-pass", "type": 1, "linkedId": null }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "66666666-6666-4666-8666-666666666666",
|
||||
"organizationId": null,
|
||||
"folderId": "f1b2c3d4-0000-4000-8000-000000000003",
|
||||
"type": 5,
|
||||
"name": "deploy@build-host",
|
||||
"notes": "Key used by CI.",
|
||||
"favorite": false,
|
||||
"reprompt": 0,
|
||||
"sshKey": {
|
||||
"privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----",
|
||||
"publicKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample deploy@build-host",
|
||||
"fingerprint": "SHA256:abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
"keyFingerprint": "SHA256:abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
},
|
||||
"creationDate": "2024-01-15T09:00:00.000Z",
|
||||
"revisionDate": "2024-01-15T09:00:00.000Z",
|
||||
"fields": null,
|
||||
"attachments": null,
|
||||
"unknownTopLevelKey": "kept because every value must be printed",
|
||||
"extraObject": { "nested": { "flag": true, "count": 3 }, "emptyList": [], "emptyObject": {} }
|
||||
}
|
||||
],
|
||||
"collections": [
|
||||
{
|
||||
"id": "col-1",
|
||||
"organizationId": "org-1",
|
||||
"name": "Shared infra",
|
||||
"externalId": null,
|
||||
"readOnly": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,590 @@
|
||||
#!/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> · {len(items)} items · {leaves} values · 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())
|
||||
Reference in new issue
Block a user