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