Skip to main content

Administrator Guide

<!-- markdownlint-disable MD013 -->
<!--
SPDX-License-Identifier: Apache-2.0
SPDX-FileCopyrightText: 2026 ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne
-->
<!-- markdownlint-enable MD013 -->

# Administrator Guide — BSI Grundschutz++ OSCAL Viewer

This guide is for operators and packagers: people who build, deploy,
update and redistribute the viewer. For day-to-day usage of the web UI
see the User Guide; for architecture and contribution rules see the
Developer Guide.

The viewer is a single, self-contained binary. The complete BSI
Grundschutz++ OSCAL catalog (OSCAL 1.1.3, 998 controls, 20 practices)
and every UI asset (Bootstrap 5, Bootstrap Icons, HTMX, Roboto fonts,
logo) are embedded at compile time. **Copying the binary to a machine
is a full installation** — there is no database, no config file, no
unpacking step and no network dependency at runtime.

## 1. Building from source

### 1.1 Prerequisites

- Rust **1.93 or newer** (enforced by `rust-version = "1.93"` in
  `Cargo.toml`; older toolchains refuse to build).
- Nothing else. The dependency tree is pure Rust — no OpenSSL, no C
  TLS stack, no system libraries beyond the platform defaults.

### 1.2 Build

```sh
cargo build --release
# binary: target/release/grundschutz-oscal-viewer
```

The release profile (`Cargo.toml`) enables `lto = true`,
`codegen-units = 1`, `strip = true` and `opt-level = 3`. The resulting
binary is approximately 14 MB (~14 030 256 bytes on macOS aarch64 for
v0.1.23) and contains:

- the catalog JSON (`data/grundschutz-plus-plus-catalog.json`, ~4 MB,
  embedded via `include_str!` in `src/index.rs`),
- the catalog change overlay
  (`data/grundschutz-plus-plus-changes.json`, embedded via `include_str!`
  in `src/changes.rs`) — the added/modified controls (with their previous
  content) versus the prior catalog edition, used to tag **Updated** / **New**
  controls and show a previous-version comparison,
- all static UI assets (embedded via `include_bytes!` in
  `src/static_files.rs`),
- the Apache-2.0 license text and the catalog `.license` sidecar
  (embedded in `src/dialog.rs` and served on the `/license` page).

Because all data is compiled in, **every catalog or asset change
requires a rebuild** — there is no runtime data directory to swap out.

### 1.3 Quick smoke test

```sh
target/release/grundschutz-oscal-viewer --version
target/release/grundschutz-oscal-viewer &
curl -fsSk https://127.0.0.1:8228/ > /dev/null && echo OK
```

On startup the log reports the parsed catalog
(`embedded catalog parsed`, with control and practice counts) and the
listen address (`BSI Grundschutz++ Viewer listening on http://…`).

### 1.4 Refreshing the embedded catalog

When the BSI publishes a new Grundschutz++ edition, refresh the vendored
data with the maintainer helper — do **not** hand-edit the catalog JSON:

```sh
scripts/update_catalog.sh
```

The script downloads the latest
`Grundschutz++-catalog.json` from the official
[Stand-der-Technik-Bibliothek](https://github.com/BSI-Bund/Stand-der-Technik-Bibliothek),
sanity-checks it, and writes four files under `data/`:

- `grundschutz-plus-plus-catalog.json` (+ `.license`) — the catalog itself;
- `grundschutz-plus-plus-changes.json` (+ `.license`) — the **change
  overlay**, regenerated by `scripts/diff_catalog.py`, listing the controls
  added or *substantively* changed (title, statement, guidance, modal verb,
  security/effort level, tags, parameters — internal `class`/UUID/namespace
  churn is ignored) plus the previous content of each changed control.

Then **rebuild to embed the new data** (`cargo build --release`) — there is
no runtime catalog directory to swap. After the rebuild, every changed
control shows an **Updated** badge with a *Previous version* comparison card,
and each new control shows a **New** badge.

Two follow-ups a catalog refresh usually needs:

- A catalog edition renumbers control ids, so any hand-maintained framework
  crosswalk in `data/mappings/*.csv` that keyed a removed id will silently
  stop displaying — re-audit those against the new id set (the overlay's
  `removed` list and `modified` entries are the ground truth).
- New embedded data files must be listed in the `Cargo.toml` `include`
  array or the crates.io publish will fail.

## 2. Command-line and environment reference

All options below are verified against the argument parser in
`src/main.rs` (`parse_args`, `meili_client`, `main`).

### 2.1 Flags

| Flag | Value | Effect |
| --- | --- | --- |
| `--port <PORT>` | u16 | Listen port (default 8228) |
| `--bind <ADDR>` | address | Bind address (default `127.0.0.1`) |
| `--meili-url <URL>` | `http://…` | Meilisearch base URL. **Disabled by default**; pass a URL (e.g. `http://localhost:7700`) to enable |
| `--meili-key <KEY>` | string | Meilisearch API key |
| `--mappings-dir <DIR>` | directory | **Optional, additive.** Framework mappings are embedded in the binary and shown by default; this only loads **extra/custom** CSVs merged on top ([details](framework_mappings_pack.md)) |
| `--export <DIR>` | directory | Write the embedded catalog, exit |
| `--export-dir <DIR>` | directory | Target directory for the **Export** menu (JSON / ODT / Markdown / PDF + sidecars). Default `<home>/grundschutz-oscal-viewer/export` (`$HOME` on Linux/macOS, `%USERPROFILE%` on Windows), created on first use |
| `--allow-non-loopback` | — | Permit binding a **non-loopback** address. The viewer has no authentication, so any `--bind` other than `127.0.0.1`/`::1` is refused unless this is passed. Place it behind an authenticating proxy. See §3.2 and `ndaal-sa-2026-120` |
| `--allowed-host <HOSTS>` | comma-separated | **Anti-DNS-rebinding.** Extra `Host`/`:authority` values the request-Host guard accepts, *in addition to* the bind address and its loopback aliases (`127.0.0.1` / `localhost` / `[::1]` on the bound port). A request whose `Host` is not allowed is refused with `403` **before dispatch**. Needed when the viewer is reached under a real hostname or via a reverse proxy that forwards the original `Host`. Env: `GSV_ALLOWED_HOSTS`. See §3.2 and `ndaal-sa-2026-288` |
| `--data-dir <DIR>` | directory | Base directory for the annotation database (default `./ndaal/data`). The DB lives at `<data-dir>/annotations.db` unless `--db` overrides the full path. See §2.7 |
| `--log-dir <DIR>` | directory | Directory for the log file (default `./ndaal/log`). Logs are written to `<log-dir>/grundschutz-oscal-viewer.log` **in addition to** the console. See §2.7 |
| `--dump-dir <DIR>` | directory | Default directory for database dumps from the Export menu (default `./ndaal/dumps`). Each dump is timestamped (ISO-8601). See §2.8 |
| `--db <FILE>` | file | SQLite database for the **Annotations** workspace. Default `<data-dir>/annotations.db` (i.e. `./ndaal/data/annotations.db`); an **empty** value disables annotations (the viewer stays read-only). Created on first use |
| `--templates-dir <DIR>` | directory | Reusable Markdown annotation templates (default `./templates`). At startup every `*.md` is validated like an upload (UTF-8, MIME, ClamAV, YARA) and the survivors fill the Annotations **Insert a template** listbox. Requires `--db`. See §2.5 |
| `--seed-demo` | — | Seed the annotation store with demonstration data on start-up (the whole catalog, every practice and every control: a Draft → Release → Draft history plus a generated demo image per target). **Non-destructive** — already-annotated targets are skipped. Requires `--db`; opt-in. See §2.6 |
| `--clamav-db-dir <DIR>` | directory | Point `clamscan --database` at a **bundled ClamAV signature directory** instead of the system database — for air-gapped hosts. Aim it at the extracted `clamav-db-<date>.tar.zst` release artifact. Used only when it actually holds a `.cvd`/`.cld`/`.cud` database; otherwise `clamscan` falls back to the system DB. See §2.4 and §10.5 |
| `--update-clamav-db` | — | Best-effort `freshclam` refresh of the ClamAV signature DB on start-up (needs `freshclam`, network access, and write permission). Writes to `--clamav-db-dir` when set, else `<home>/grundschutz-oscal-viewer/clamav-db`. A failed refresh is logged and ignored. See §2.4 and §10.5 |
| `--check-update` | — | Check `gitlab.com/vPierre/ndaal_public_bsi_grundschutz_oscal_viewer` for a newer release and print the result, then exit. Read-only; an unreachable host is reported ("up to date, could not check"), never an error. See §2.9 |
| `--self-update` | — | Download the latest release for the running target triple, **verify it against the committed `release/SHA256SUMS`**, atomically replace the running binary, then exit. Fetches over TLS 1.3 (aws-lc-rs); never downgrades and never installs on a checksum mismatch. Needs network + write permission on the binary's own path. See §2.9 |
| `--no-self-update` | — | Policy opt-out (env: `GSV_NO_SELF_UPDATE`): make `--self-update` refuse (exit non-zero). For package-managed or locked-down installs where the binary must not replace itself. `--check-update` (read-only) stays available. See §2.9 |
| `--help`, `-h` | — | Print usage, exit |
| `--version`, `-V` | — | Print version, exit |

Unknown flags and missing option values are rejected with an error
(`unknown option: … (see --help)`); the process exits non-zero. A
non-numeric `--port` value is also a hard error.

### 2.2 Environment variables

| Variable | Fallback for | Notes |
| --- | --- | --- |
| `GSV_PORT` | `--port` | Invalid values are ignored; 8228 is used |
| `GSV_BIND` | `--bind` | Default `127.0.0.1` |
| `MEILI_URL` | `--meili-url` | **Unset by default → Meilisearch disabled.** Set a URL (e.g. `http://localhost:7700`) to enable it |
| `MEILI_MASTER_KEY` | `--meili-key` | Empty values count as unset |
| `GSV_MAPPINGS_DIR` | `--mappings-dir` | Optional/additive — mappings are embedded by default; sets an extra pack directory. Empty/blank values count as unset |
| `GSV_EXPORT_DIR` | `--export-dir` | Empty/blank values count as unset (falls back to `<home>/grundschutz-oscal-viewer/export`) |
| `GSV_DB` | `--db` | Annotation SQLite database. Unset → default path; an **empty** value disables annotations. Write endpoints require a same-origin request and cap bodies at 2 MiB; uploaded PNG/SVG are validated and served only as attachments |
| `GSV_TEMPLATES_DIR` | `--templates-dir` | Directory of reusable Markdown annotation templates. Empty/blank counts as unset (falls back to `./templates`). See §2.5 |
| `GSV_SEED_DEMO` | `--seed-demo` | Seed demonstration annotations on start-up (any non-empty value other than `0`, or the flag). Requires `--db`; non-destructive. See §2.6 |
| `GSV_DATA_DIR` | `--data-dir` | Base directory for the annotation DB (default `./ndaal/data`). Empty/blank counts as unset |
| `GSV_LOG_DIR` | `--log-dir` | Directory for the log file (default `./ndaal/log`). Empty/blank counts as unset |
| `GSV_DUMP_DIR` | `--dump-dir` | Default directory for database dumps (default `./ndaal/dumps`). Empty/blank counts as unset |
| `GSV_ALLOW_NON_LOOPBACK` | `--allow-non-loopback` | Permit a non-loopback bind (any non-empty value other than `0`, or the flag). See §3.2 |
| `GSV_ALLOWED_HOSTS` | `--allowed-host` | Comma-separated extra `Host`/`:authority` values accepted by the anti-DNS-rebinding guard, beyond the bind address and its loopback aliases. A foreign `Host` is refused with `403`. See §3.2 |
| `GSV_NO_SELF_UPDATE` | `--no-self-update` | Forbid `--self-update` (any non-empty value other than `0`, or the flag). `--check-update` stays available. See §2.9 |
| `GSV_YARA_RULES` | `--yara-rules` | YARA rule file/dir for scanning uploaded images. Unset → default `<home>/grundschutz-oscal-viewer/yara-rules`; an **empty** value disables the YARA pass. See §2.4 |
| `GSV_NO_FETCH_YARA_RULES` | `--no-fetch-yara-rules` | Disable the startup auto-download of the YARA Forge rule set (any non-empty env value, or the flag). See §2.4 |
| `GSV_CLAMAV_DB_DIR` | `--clamav-db-dir` | Bundled ClamAV signature directory for `clamscan --database`. Empty/blank counts as unset → the system DB is used. See §2.4 / §10.5 |
| `GSV_UPDATE_CLAMAV_DB` | `--update-clamav-db` | Refresh the ClamAV signature DB with `freshclam` on start-up (any non-empty value other than `0`, or the flag). Best-effort; failure is logged and ignored. See §2.4 / §10.5 |
| `RUST_LOG` | — | `tracing` filter; `info` if unset/invalid |

Precedence is always **CLI flag, then environment variable, then
built-in default**.

### 2.3 Logging

Logs go to stdout/stderr via `tracing_subscriber`. Useful filters:

```sh
RUST_LOG=debug grundschutz-oscal-viewer        # connection-level detail
RUST_LOG=warn  grundschutz-oscal-viewer        # only problems
```

The server logs no request bodies and sets `Referrer-Policy:
no-referrer`; there is no access log by default.

### 2.4 Upload scanning (ClamAV + YARA)

When annotations are enabled (`--db`), every uploaded image is checked
before it is written to the database. The pipeline is:

1. **MIME + structure** — declared type must match the sniffed bytes
   (`infer`); PNG signature/`IHDR` and an SVG element allow-list are
   enforced. SVG bytes are additionally validated as UTF-8 (`simdutf8`).
2. **Malware scan** — the bytes are written to a process-private temp
   file and scanned with **ClamAV** (`clamscan`, no `clamd` required) and
   **YARA** (`yr`, the YARA-X CLI). A finding rejects the upload with
   HTTP `422` and nothing is stored.

The policy is **fail-open on absence, fail-closed on detection**: if a
scanner binary, its virus database, or the YARA rules are missing, that
pass is logged and skipped — the viewer still runs and stores clean
uploads. Only a positive detection blocks an upload.

Install `clamscan` (ClamAV) and `yr` (YARA-X) and keep ClamAV's virus
database current with `freshclam` — see **§10** for step-by-step,
per-platform (Windows / Linux / macOS) instructions.

**ClamAV virus database — offline / air-gapped option.** By default
`clamscan` uses the system-wide virus database that `freshclam` installs.
Two flags let you supply the database out-of-band instead, so the scanner
works on a host that cannot reach the ClamAV mirrors:

- `--clamav-db-dir <DIR>` / `GSV_CLAMAV_DB_DIR` points
  `clamscan --database` at a directory you provide — typically the
  extracted `clamav-db-<date>.tar.zst` artifact shipped beside each release
  (built by `scripts/update_clamav_db.sh`, ~300 MB).
- `--update-clamav-db` / `GSV_UPDATE_CLAMAV_DB` runs `freshclam` once at
  start-up to (re)fill that directory, where the host has network access.

The signature directory is resolved in this order — the first that actually
holds a `.cvd`/`.cld`/`.cud` database wins; a missing or empty candidate
simply falls through:

1. `--clamav-db-dir` (the bundled artifact),
2. a `--update-clamav-db` freshclam refresh — into `--clamav-db-dir`, else
   `<home>/grundschutz-oscal-viewer/clamav-db`,
3. the system ClamAV database (no flags),
4. *fail-open* — no usable database, so the ClamAV pass is skipped (YARA
   still runs).

The database is **never embedded in the binary** (it is ~300 MB, changes
daily, and embedding it would break the crates.io 10 MiB crate limit). See
§10.5 for the full air-gapped walk-through.

**YARA rules — automatic at startup.** When the rules directory is empty,
the viewer downloads the latest
[YARA Forge](https://github.com/YARAHQ/yara-forge) release itself on the
first boot, verifies the SHA-256 digest published in the GitHub release
metadata, and extracts it into the rules directory. Release discovery,
digest verification, and the unzip are done in Rust; only the HTTPS
transfer is delegated to `curl` (the BSI-TR-02102-2 hardened recipe —
never `-k`). Extraction is hardened against zip-slip, zip bombs, and
partial writes (files are staged and atomically renamed). The server
starts listening **before** the fetch runs and the fetch is time-bounded,
so an offline boot is never blocked. After the first successful fetch the
rules are cached on disk and no further download happens.

Opt out with `--no-fetch-yara-rules` / `GSV_NO_FETCH_YARA_RULES` (for
air-gapped hosts, or to manage rules yourself). The standalone fetcher is
still available — useful for staging rules out-of-band or choosing a
larger rule package:

```sh
# default target: <home>/grundschutz-oscal-viewer/yara-rules
scripts/fetch_yara_rules.sh
# a custom directory / rule package (core|extended|full)
GSV_YARA_PACKAGE=extended scripts/fetch_yara_rules.sh /srv/yara-rules
grundschutz-oscal-viewer --db notes.db --yara-rules /srv/yara-rules --no-fetch-yara-rules
```

### 2.5 Reusable annotation templates (`--templates-dir`)

When annotations are enabled (`--db`), the editor offers an **Insert a
template** listbox. It is filled at startup from the Markdown files in the
templates directory (`--templates-dir` / `GSV_TEMPLATES_DIR`, default
`./templates`). Choosing an entry inserts its text at the editor's cursor (or
at the start of the field when no cursor was placed).

Each `*.md` file is admitted only after passing the **same gate as an
uploaded image**, adapted to Markdown text:

1. **UTF-8** — the bytes must decode as valid UTF-8 (`simdutf8`).
2. **MIME** — the content must not sniff as a binary format (`infer`); real
   Markdown has no magic bytes, so a `%PDF`/PNG/WASM payload renamed to `.md`
   is rejected.
3. **ClamAV + YARA** — the same `clamscan` + `yr` scan as uploads, with the
   same **fail-open on absence, fail-closed on detection** policy.

A file that fails any check (or is over 1 MiB, empty, or not `*.md`) is logged
and **skipped — never stored**. The survivors replace the store's template set
in one transaction (so the listbox always mirrors the current directory), are
capped at 500, and are served as inert `text/plain` from
`GET /annotations/template/{name}` — a database-key lookup, never a filesystem
path. The directory is opened through a read-only capability handle
(`cap-std`), so no file outside it can be reached. The import runs **after**
the listener is bound, so a slow scan never blocks start-up.

### 2.6 Demonstration data (`--seed-demo`)

To explore the **Annotations** workspace without authoring notes by hand, start
the viewer with `--seed-demo` (or `GSV_SEED_DEMO=1`). It requires `--db` —
annotations must be enabled — and seeds demonstration data across **every**
catalog level:

- the whole-catalog overview (1 target),
- each practice / domain (20 targets), and
- every control (998 targets — the full ~1000-control annotation sequence).

That is **1019 targets** in total for the shipped catalog.

Each seeded target gets a short version history (a **Draft**, a **Release** and
a follow-up **Draft**) plus one generated **demo image**. The image is a tiny,
inert SVG badge produced in-process — no extra data files ship in the binary —
and it is validated through the very same upload allow-list as a user upload, so
the seeded corpus is policy-clean by construction.

Seeding is **non-destructive**: any target that already carries an annotation is
left untouched. The flag is therefore safe to leave enabled — it only ever fills
*empty* targets — and re-running it is a no-op. It runs **once, before the
listener binds** (so the data is present the moment the viewer answers, in a
single atomic transaction) and logs a one-line summary. On a fresh database:

```text
demo annotations seeded seeded=1019 skipped=0 versions=3057 assets=1019
```

Each target receives three versions (`1019 × 3 = 3057`) and one image. On a
store that already holds annotations the `skipped` count rises accordingly and
those targets keep their existing notes — for example `seeded=21 skipped=998`
on a store whose 998 controls were already annotated (only the catalog and the
20 practices were empty).

To get a clean demonstration database, point `--db` at a fresh file:

```bash
grundschutz-oscal-viewer --db ./demo-annotations.db --seed-demo
```

To remove the demonstration data afterwards, stop the viewer and delete that
database file (and its `-wal` / `-shm` siblings). The flag is intended for demos,
training and screenshots — not for production stores, where it would simply skip
your real annotations.

`test/bruno/run-seed-demo.sh` spins up a throwaway seeded viewer and runs a
small Bruno HTTP suite against it (catalog / practice / control demo annotations
plus the demo image), so the feature can be verified end-to-end without touching
your own database.

### 2.7 The `ndaal/` working directory

By default the viewer keeps its mutable state under `./ndaal/` (relative to the
current directory), so a fresh checkout or a packaged binary is self-contained:

- `--data-dir` (`GSV_DATA_DIR`, default `./ndaal/data`) — the annotation database
  lives at `<data-dir>/annotations.db`. `--db` still overrides the full path (and
  an empty `--db` disables annotations entirely).
- `--log-dir` (`GSV_LOG_DIR`, default `./ndaal/log`) — logs are written to
  `<log-dir>/grundschutz-oscal-viewer.log` (append-only) **and** to the console.
  If the directory or file cannot be opened, the viewer logs to the console only.
- `--dump-dir` (`GSV_DUMP_DIR`, default `./ndaal/dumps`) — the default target for
  database dumps (§2.8).

Each directory is created on first use. Because the defaults are **relative**,
run the viewer from a stable working directory (or pass absolute paths) for a
predictable layout.

### 2.8 Database dumps (Export menu)

The Export page (`/export`) has an **Export database dump** button (shown when
`--db` is set). It writes, for the annotation store:

- a consistent SQLite snapshot via `VACUUM INTO` — `annotations-<ISO8601>.db`;
- a portable SQL text dump (schema DDL + `INSERT` rows, BLOBs as hex literals) —
  `annotations-<ISO8601>.sql`;

each with **selectable** checksum sidecars (`.sha-256` / `.sha-512` /
`.sha3-512` / `.blake3-512` / `.shake256-512` — all five enabled by default, tick
the boxes to choose). The dump form's directory field is an **optional relative
sub-path under** the `--dump-dir` root (leave it empty for the root itself);
absolute paths and `..` components are rejected, so a dump can never be steered
outside the configured root. The trigger is a same-origin `POST` that runs on the
blocking pool behind a small concurrency limit (a 503 is returned if another
export/dump is already running), the files are written through a
capability-scoped `cap-std` handle with no-clobber (`create_new`) semantics, and
the ISO-8601 UTC timestamp has `:` replaced by `-` so the names are valid on
every platform.

To restore: copy the `.db` snapshot into place and point `--db` at it, or replay
the SQL dump with `sqlite3 new.db < annotations-<ISO8601>.sql`. A large store with
many image attachments produces a large SQL dump (BLOBs are hex-expanded), so the
snapshot is usually the more economical backup.

### 2.9 Self-update (`--check-update` / `--self-update`)

The binary can update itself from the project's own GitLab releases. Both flags
are one-shot early actions: they run, print a line, and exit before the server
starts.

```sh
grundschutz-oscal-viewer --check-update   # report only, never fatal
grundschutz-oscal-viewer --self-update    # download + verify + replace, then exit
```

- **`--check-update`** queries
  `gitlab.com/vPierre/ndaal_public_bsi_grundschutz_oscal_viewer` for the newest
  release and prints one of: "up to date", "a newer release is available: X → Y",
  or "could not reach the update host". A network failure is **reported, not an
  error** (exit 0), so it is safe to call from a boot script.
- **`--self-update`** determines the running target triple, downloads the
  matching release asset
  (`grundschutz-oscal-viewer-<version>-<triple>[.exe]`), **verifies its SHA-256
  against the `release/SHA256SUMS` committed at that tag**, and then atomically
  replaces the running executable. It **never downgrades** (a from-source build
  that is ahead of the latest published release is left alone) and **never
  installs on a checksum mismatch** or if the asset is missing from the manifest.

**Transport.** All requests ride the viewer's own hyper client over **TLS 1.3
only**, using the same `aws-lc-rs` crypto provider as the server — the
[`self_update`](https://crates.io/crates/self_update) crate's own HTTP client is
compiled but never used, so no second crypto stack, no TLS 1.2, and no
`reqwest`/`openssl` enter the binary.

**Trust model (honest).** The checksum is fetched from the *same* release over
the *same* TLS channel, so it protects against a corrupted or asset-swapped
download and man-in-the-middle tampering below TLS — it is **not** a signature.
An attacker able to rewrite both the binary and `SHA256SUMS` at the source would
defeat it. For higher assurance, verify the release's five-hash sidecars out of
band, or install from crates.io with `cargo install` / `cargo binstall`.

**Requirements.** Network access to `gitlab.com` and write permission on the
running binary's own path (self-update replaces the file in place). On a
read-only or package-manager-owned install, update through the package manager
instead.

**Disabling it (`--no-self-update` / `GSV_NO_SELF_UPDATE`).** Set either to make
`--self-update` refuse and exit non-zero — for package-managed or locked-down
deployments where the binary must never replace itself. Put
`GSV_NO_SELF_UPDATE=1` in the service environment (e.g. the systemd unit) so the
policy holds regardless of how the binary is invoked. `--check-update`
(read-only) is not affected.

**Version pinning (integrity).** `--self-update` pins the exact release tag
whose asset it checksummed, so the version installed and the version verified
can never diverge — it will not install one release and check it against
another's `SHA256SUMS`.

**Release-time guarantee.** The release pipeline's step 3c
(`release/verify_selfupdate_artifacts.sh`) asserts that every one of the six
target-triple assets for the release is present, listed by its exact name in
`release/SHA256SUMS`, and checksum-matched — so a shipped binary can always find
and verify its successor.

## 3. Deployment model

### 3.1 Localhost by default — and on purpose

The viewer binds to `127.0.0.1:8228` unless told otherwise. It is
designed as a local, read-only reference tool: every route is `GET`
(plus `HEAD`), there are no accounts, no sessions, no uploads and no
state to corrupt.

### 3.2 TLS and binding to non-localhost addresses

The viewer serves **HTTPS only** (TLS 1.3 via rustls, aws-lc-rs
provider with `prefer-post-quantum` — the X25519MLKEM768 hybrid
key-exchange group is offered first). A self-signed certificate for
`localhost` / `127.0.0.1` / `::1` is **regenerated at every start-up**
(45-day validity). There is no plaintext-HTTP fallback; clients that
speak plain HTTP to the port get a TLS handshake error.

Because the certificate is self-signed, browsers show a one-time
"not trusted" warning on first connect, and scripted clients need
`curl -k` / `wget --no-check-certificate`. To remove the warning,
front the viewer with a reverse proxy that presents a CA-issued
certificate, or install the start-up certificate into the client trust
store (it changes on every restart, so a proxy is usually preferable).

> **Warning.** `--bind 0.0.0.0` (or any non-loopback address) exposes
> the viewer to the network. TLS is on, but the certificate is
> self-signed and there is **no authentication**. For untrusted
> networks, keep the viewer on `127.0.0.1` and put a reverse proxy in
> front that presents a trusted certificate and (where required)
> authenticates clients:

```text
client ──HTTPS──> reverse proxy (trusted cert, auth) ──HTTPS──> 127.0.0.1:8228
```

Example nginx location block (note `https://` upstream and skipping
verification of the self-signed start-up certificate):

```nginx
location / {
    proxy_pass https://127.0.0.1:8228;
    proxy_ssl_verify off;
    proxy_set_header Host $host;
}
```

Notes for proxy operators:

- The viewer **sends `Strict-Transport-Security`**
  (`max-age=63072000; includeSubDomains; preload`) on every response,
  since the listener is TLS-only. Keep or override it at the proxy as
  your HSTS policy requires.
- All content is same-origin and self-contained; no upstream hosts
  need to be whitelisted in the proxy.
- Dynamic HTML responses carry `Cache-Control: no-store`; embedded
  `/static/` assets carry `Cache-Control: public, max-age=3600`.
- **`Host` allowlist (anti-DNS-rebinding).** The viewer pins every
  request's `Host` / `:authority` to the bind authority plus its loopback
  aliases (`127.0.0.1` / `localhost` / `[::1]` on the bound port); a
  foreign `Host` is refused with `403` before dispatch. The
  `proxy_set_header Host $host;` line above forwards the client's
  **public** hostname, so you must allow it with
  `--allowed-host <hostname>` / `GSV_ALLOWED_HOSTS` (comma-separated for
  several) — **or** have the proxy send the loopback authority instead
  (`proxy_set_header Host 127.0.0.1:8228;`), which is always accepted.
  Without one of these, a proxied request under a real hostname returns
  `403 host not allowed`. See `ndaal-sa-2026-288`.

### 3.2.1 Using your own certificate (e.g. Let's Encrypt)

The viewer always generates its **own** short-lived self-signed certificate
for its listener and has **no `--cert` / `--key` option** — it never loads an
external certificate file. To present a **CA-issued** certificate (Let's
Encrypt, an internal CA, a corporate wildcard, …) to clients, terminate TLS
at a **reverse proxy** and forward to the viewer's HTTPS listener on
`127.0.0.1`:

```text
client ──HTTPS (Let's Encrypt cert)──> reverse proxy ──HTTPS (self-signed)──> 127.0.0.1:8228
```

Keep the viewer bound to localhost (the default) so only the proxy reaches it,
and let the proxy own the public certificate. Because the viewer's upstream
certificate is self-signed, the proxy must **skip verification of the
upstream** — the loopback hop is still TLS-encrypted, just not CA-verified.

**Caddy** obtains and renews a Let's Encrypt certificate automatically; a
two-line site block is enough:

```caddy
viewer.example.org {
    reverse_proxy https://127.0.0.1:8228 {
        transport http {
            tls
            tls_insecure_skip_verify
        }
    }
}
```

**nginx + certbot** — obtain the certificate once
(`certbot --nginx -d viewer.example.org`), then point the proxy at the viewer:

```nginx
server {
    listen 443 ssl;
    server_name viewer.example.org;

    ssl_certificate     /etc/letsencrypt/live/viewer.example.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/viewer.example.org/privkey.pem;

    location / {
        proxy_pass https://127.0.0.1:8228;   # upstream is HTTPS (self-signed)
        proxy_ssl_verify off;                # do not verify the self-signed cert
        proxy_set_header Host $host;
    }
}
```

The ACME/certbot renewal reloads the proxy; the viewer needs no restart and
keeps regenerating its own self-signed certificate behind the proxy — clients
only ever see the trusted Let's Encrypt certificate. (If you would rather the
viewer load a certificate file directly, that is not supported today; open an
issue if you need it.)

### 3.3 Built-in hardening (verified in `src/main.rs` / `src/router.rs`)

- **Connection cap:** at most **512 concurrent connections**
  (`MAX_CONNECTIONS`); further connections are shed with a warning
  instead of exhausting tasks and memory.
- **Header-read timeout:** **30 seconds** (`HEADER_READ_TIMEOUT`)
  reclaims sockets that stall while sending the request head
  (slowloris mitigation).
- **Security headers on every response** (including errors), carried
  over from the ndaal `vulnerability-lookup-rs` middleware
  (OWASP ASVS V14.4.x):
  - `X-Frame-Options: DENY`
  - `Content-Security-Policy` with `default-src 'self'`,
    `frame-ancestors 'none'`, `base-uri 'none'`,
    `form-action 'self'`, `object-src 'none'`; `script-src` pins the
    two inline `<script>` blocks by **SHA-256 hash** — there is no
    `'unsafe-inline'` for scripts, so the CSP remains a real backstop
    against injected scripts (`style-src` keeps `'unsafe-inline'` for
    the inline style block).
  - `X-Content-Type-Options: nosniff`
  - `Referrer-Policy: no-referrer`
  - `Permissions-Policy` denying accelerometer, camera, geolocation,
    gyroscope, magnetometer, microphone, payment, usb and
    interest-cohort
  - `Cross-Origin-Opener-Policy: same-origin`,
    `Cross-Origin-Resource-Policy: same-origin`,
    `Cross-Origin-Embedder-Policy: require-corp`
- **Method discipline:** only `GET` routes exist; `HEAD` is served
  through the `GET` tree (RFC 9110 §9.1); other methods on known paths
  get `405` with an `Allow: GET, HEAD` header.
- **Memory safety:** `#![forbid(unsafe_code)]`, panicking shortcuts
  (`unwrap`, `panic`, indexing, lossy casts) denied by lint policy.

### 3.4 Process management

The server runs in the foreground and shuts down cleanly on **Ctrl-C
(SIGINT)** — `tokio::signal::ctrl_c()` is the only shutdown hook.
A minimal systemd unit therefore sends SIGINT:

```ini
[Unit]
Description=BSI Grundschutz++ OSCAL Viewer
After=network.target

[Service]
ExecStart=/usr/local/bin/grundschutz-oscal-viewer
Environment=GSV_PORT=8228
KillSignal=SIGINT
DynamicUser=yes
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes

[Install]
WantedBy=multi-user.target
```

The process needs no writable paths, no capabilities and no network
egress (unless Meilisearch is configured), so aggressive sandboxing is
safe.

There is no state on disk: backup/recovery for the viewer is "keep a
copy of the binary".

### 3.5 Upgrading — replacing the binary

The viewer is a single self-contained binary, so an upgrade **is** just
replacing that one file. The catalog is embedded, the TLS certificate is
regenerated on every start, and the annotation database (`--db`) and the
YARA rules directory live elsewhere — none of them are touched by a binary
swap.

You cannot copy a new build over the binary while it is still running:

```text
cp: cannot create regular file '/usr/local/bin/grundschutz-oscal-viewer': Text file busy
```

`cp` truncates the destination before writing, and Linux refuses to
truncate an executable that is **currently running** (the ELF image is
memory-mapped), returning `ETXTBSY` — *"Text file busy"*. The fix is to
**stop the process, copy the new binary, then start it again**:

```sh
# 1. stop the running viewer
pkill -TERM -f /usr/local/bin/grundschutz-oscal-viewer   # SIGTERM; or: kill -TERM <pid>

# 2. replace the now-free binary and make it executable
cp grundschutz-oscal-viewer-0.1.23-x86_64-unknown-linux-musl \
   /usr/local/bin/grundschutz-oscal-viewer
chmod 0755 /usr/local/bin/grundschutz-oscal-viewer

# 3. start the app again
systemctl start grundschutz-oscal-viewer    # or run it directly / under nohup
```

Then confirm the swap took effect:

```sh
grundschutz-oscal-viewer --version
```

Notes:

- **SIGTERM** stops the process via its default action; **SIGINT**
  (Ctrl-C) is the in-process graceful-shutdown hook. Either one frees the
  binary so step 2 succeeds. The annotation store is SQLite in WAL mode
  (crash-safe), so stopping with SIGTERM mid-request never corrupts it.
- Pick the artifact that matches your platform (the Linux build is a fully
  static musl binary) and verify it against the published checksums before
  installing:

  ```sh
  sha256sum -c SHA256SUMS 2>/dev/null | grep x86_64-unknown-linux-musl
  ```

## 4. Meilisearch (optional, end to end)

The built-in full-text search works offline and is always available.
Pointing the viewer at a [Meilisearch](https://www.meilisearch.com/)
instance adds typo tolerance and relevance ranking. The integration is
implemented in `src/meili.rs` / `src/app_state.rs`.

> **Meilisearch is disabled by default — it is opt-in.** The built-in
> search answers `/search` out of the box; the binary never *depends* on
> Meilisearch. To enable it, **run a Meilisearch server** (§4.1) and point
> the viewer at it with **`--meili-url`** (or `MEILI_URL`), e.g.
> `--meili-url http://localhost:7700`. The viewer is only a *client*; it
> does **not** bundle or start a Meilisearch **server**. With Meilisearch
> enabled but unreachable, the log shows
> `meilisearch not reachable — built-in search stays active` and the
> built-in engine keeps answering — the viewer also falls back to the
> built-in engine on any per-query Meilisearch error.

### 4.1 Start a local instance

```sh
docker run -d --name meilisearch \
  -p 127.0.0.1:7700:7700 \
  -e MEILI_MASTER_KEY='change-me-32-bytes-minimum......' \
  -v meili_data:/meili_data \
  getmeili/meilisearch:latest
```

Bind the container port to `127.0.0.1` — the viewer is meant to talk
to a **local** instance. Always set a master key; without one,
Meilisearch accepts unauthenticated writes.

### 4.2 Connect the viewer

Meilisearch is opt-in, so enable it with `--meili-url`; add a key if your
instance requires one:

```sh
grundschutz-oscal-viewer --meili-url http://localhost:7700 \
  --meili-key 'change-me-32-bytes-minimum......'
# or: MEILI_URL=http://localhost:7700 MEILI_MASTER_KEY=… grundschutz-oscal-viewer
# point at a remote instance: --meili-url https://meili.internal:7700
```

**Transport policy** (enforced in `MeiliClient::new` / the connector):

- **Loopback** hosts (`localhost`, `127.0.0.1`, `::1`) may use plain
  `http://` — there is no network exposure on the loopback interface. This is
  the default (`http://localhost:7700`).
- **Any other host** must use `https://`. The client is rustls-protected
  (system trust store, **TLS 1.3 only**), so the API key and queries to a
  remote instance never cross the network in cleartext.
- A **remote `http://`** URL is **rejected** — cleartext is not allowed off
  the loopback interface.

The key is sent as `Authorization: Bearer <key>`; the client's debug output
redacts it.

### 4.2.1 Encrypting the Meilisearch connection (TLS)

A loopback `http://localhost:7700` is not network-exposed, but you can also
encrypt the local hop. Run Meilisearch with TLS and point the viewer at it
over `https://`, supplying the certificate's CA with `--meili-ca` so the
self-signed / internal-CA certificate is trusted (it is added **on top of**
the system trust store; the connection is TLS 1.3 only):

```sh
# 1. A certificate + key for localhost (self-signed example; or use your CA):
openssl req -x509 -newkey rsa:4096 -nodes -days 365 \
  -keyout meili-key.pem -out meili-cert.pem \
  -subj '/CN=localhost' -addext 'subjectAltName=DNS:localhost,IP:127.0.0.1'

# 2. Run Meilisearch with TLS:
meilisearch --master-key '…' \
  --ssl-cert-path meili-cert.pem --ssl-key-path meili-key.pem
#   (Docker: bind-mount the two files and add the same --ssl-* flags.)

# 3. Point the viewer at the TLS instance and trust the certificate:
grundschutz-oscal-viewer \
  --meili-url https://localhost:7700 \
  --meili-ca  meili-cert.pem \
  --meili-key '…'
# or via environment: MEILI_URL / MEILI_CA / MEILI_MASTER_KEY
```

Without `--meili-ca`, a self-signed `https://` Meilisearch is **rejected**
(the system trust store does not know the certificate) — the viewer never
disables certificate verification. For a remote instance, prefer a CA-issued
certificate (e.g. behind a reverse proxy, §3.2.1), which needs no
`--meili-ca` at all.

### 4.3 What happens at startup

`AppState::spawn_meili_indexing` runs in the background and does not
block or fail the server:

1. `GET /health` — if the instance is unreachable, a warning is
   logged (`meilisearch not reachable — built-in search stays
   active`) and nothing else happens.
2. `POST /indexes` creates the index **`grundschutz`** with primary
   key `doc_id` (idempotent — an existing index / HTTP 409 is fine).
3. All 998 controls are pushed as documents. Document ids replace the
   dots in control ids with dashes (`GC.1.1` → `GC-1-1`, Meilisearch
   only allows `[a-zA-Z0-9_-]`); the real id is kept in `control_id`.
4. On success the ready flag flips and the log shows
   `meilisearch index populated — full-text search active`.

### 4.4 Fallback behaviour

- If startup indexing fails or the instance is down, `/search` is
  answered by the built-in engine — the binary never depends on
  Meilisearch.
- Even when Meilisearch is ready, **every individual query** that
  errors falls back to the built-in engine (logged as
  `meilisearch query failed, using built-in engine`).
- The search results page shows which engine answered.

### 4.5 Operational notes

- The viewer only ever creates the index and adds/replaces documents;
  it never deletes. After a catalog update (section 5), restart the
  viewer — documents are re-pushed and replaced by primary key. If a
  control id was *removed* from the catalog, delete the stale index
  once (`curl -X DELETE -H "Authorization: Bearer <key>"
  http://localhost:7700/indexes/grundschutz`) and restart the viewer.
- Multiple viewer instances may share one Meilisearch; they all write
  the same `grundschutz` index.

## 5. Refreshing the embedded catalog

The catalog is vendored at `data/grundschutz-plus-plus-catalog.json`
with a REUSE sidecar `data/grundschutz-plus-plus-catalog.json.license`
that records source, branch, retrieval date and the BSI
`metadata.version` stamp.

### 5.1 Procedure

```sh
scripts/update_catalog.sh   # needs curl and python3
cargo build --release       # re-embed the new catalog
```

The script (verified in `scripts/update_catalog.sh`):

1. downloads the current
   `Anwenderkataloge/Grundschutz++/Grundschutz++-catalog.json` from
   the official `BSI-Bund/Stand-der-Technik-Bibliothek` repository
   (branch `main`),
2. sanity-checks that the download is valid JSON with the OSCAL
   catalog shape **before** replacing anything (a failed download or
   parse leaves the old catalog untouched),
3. replaces the vendored file and rewrites the `.license` sidecar
   with the new retrieval date and `metadata.version`.

Afterwards verify: `target/release/grundschutz-oscal-viewer` logs the
new catalog version at startup, and `/metadata` shows the OSCAL
metadata of the embedded copy.

### 5.2 CC-BY-SA-4.0 obligations

The catalog is © Bundesamt für Sicherheit in der Informationstechnik
(BSI), licensed **CC-BY-SA-4.0**. When you refresh and redistribute:

- keep the attribution to the BSI (the sidecar and the viewer's
  `/license` page provide it),
- keep the license notice (`LICENSES/CC-BY-SA-4.0.txt` and the
  sidecar),
- redistribute the catalog data unmodified, or clearly mark changes
  and license adaptations of the *data* under CC-BY-SA-4.0 as well
  (ShareAlike). The viewer redistributes the file byte-for-byte —
  `/catalog.json` serves exactly the embedded bytes.

## 6. Exporting the embedded catalog (`--export`)

```sh
grundschutz-oscal-viewer --export /some/dir
# catalog exported to /some/dir/grundschutz-plus-plus-catalog.json
```

This is a one-shot action; the process exits afterwards. Safety
guarantees (verified in `export_catalog`, `src/main.rs`):

- The target directory is opened **once** as a capability-based
  `cap_std::fs::Dir` handle; all file creation goes through that
  handle, so **no path outside the directory can be reached** —
  symlink tricks and `..` traversal cannot escape it.
- The file is created with **`create_new` semantics**: an existing
  `grundschutz-plus-plus-catalog.json` is **never overwritten**; the
  export fails with an error instead.

The exported file is the embedded catalog byte-for-byte, identical to
what `/catalog.json` serves.

### 6.1 Multi-format export via the **Export** menu

The navbar **Export** page (`/export`) and its `GET /export/run` action
write richer artifacts into the configured `--export-dir` (default
`<home>/grundschutz-oscal-viewer/export` — `$HOME` on Linux/macOS,
`%USERPROFILE%` on Windows; `GSV_EXPORT_DIR`):

- **Scope** — the whole Grundschutz++ catalog, a single practice
  (domain), or a single control.
- **Formats** — `json` (raw OSCAL), `markdown`, `odt` and `pdf`.
  Markdown and ODT are rendered in-process by `lo_writer` (the pure-Rust
  libreoffice-rs document model — no external LibreOffice, no shelling
  out). PDF is rendered directly (`src/export_pdf.rs`) with the bundled
  Roboto TrueType font embedded, so German umlauts (`ä ö ü ß`) are
  faithful and selectable — `lo_writer`'s PDF backend is ASCII-only and
  would drop them.
- **Checksum sidecars** — for every artifact, up to five GNU-`shasum`
  files (`.sha-256`, `.sha-512`, `.sha3-512`, `.blake3-512`,
  `.shake256-512`), selectable on the form.
- **Overwrite existing files** — a checkbox, ticked by default, so
  re-running an export replaces its previous files (opened with
  `create+truncate`). Unticking it submits `overwrite=off`, which makes the
  writer use `create_new` and fail rather than replacing any existing file.
  Overwrite is the default whenever the parameter is absent (a bare
  `/export/run` call), so re-exports never surface a raw `File exists`
  error.

These writes share the one-shot exporter's capability posture: a single
capability-scoped `cap_std::fs::Dir` handle, and `--export-dir` is
**server-side configuration — never taken from the request**, so the scope
ids cannot direct a write outside the directory.

The same `/export` page also offers a **database dump** (a SQLite snapshot
plus a SQL text dump of the annotation store, each with selectable checksum
sidecars) — see §2.8. It sits beside the catalog export in a two-column
layout. Together with `--export` (one-shot) and the export-menu trigger, the
dump is the binary's third and final write path; everything else is read-only.
The export-menu and database-dump triggers are **same-origin `POST`s** (a
cross-site page cannot drive them); keep the listener on localhost (the
default) so the write triggers are not exposed to other hosts.

## 7. QA and verification

Before packaging or deploying a self-built binary, run the full gate
set:

```sh
just qa            # = bash scripts/run_qa.sh
```

Gates (verified in `scripts/run_qa.sh`):

| Gate | Command | Tool |
| --- | --- | --- |
| rustfmt | `cargo fmt --all --check` | always |
| clippy | `cargo clippy --all-targets --all-features -- -D warnings` | always |
| tests | `cargo test` | always |
| doctests | `cargo test --doc` | always |
| doc-build | `RUSTDOCFLAGS="-D warnings" cargo doc --no-deps` | always |
| cargo-audit | `cargo audit` | optional |
| cargo-deny | `cargo deny check` | optional |
| cargo-machete | `cargo machete` | optional |
| rust-doctor | `rust-doctor` | optional |
| htmlhint | template lint (`src/templates/**/*.html`) | optional |
| oxlint | inline-`<script>` lint (extracted from templates) | optional |

Optional gates are **skipped with a notice** when the tool is not
installed; **every gate that runs must pass** or the script exits 1
and lists the failed gates. Gate configuration lives at the repository
root: `clippy.toml`, `rustfmt.toml`, `audit.toml`, `deny.toml`,
`rust-doctor.toml`, `.oxlintrc.json` and
`src/templates/.htmlhintrc`.

Note for packagers: the CSP script hashes (section 3.3) are covered by
the test suite (`tests/test_routes.rs`,
`csp_script_hashes_match_inline_scripts`) — if you patch the inline
scripts in `src/templates/layouts/base.html`, `just qa` will catch a
stale hash.

PDF umlaut fidelity (section 6.1) is gated two ways: `tests/test_export_pdf.rs`
decodes every exported control's text back through the PDF's ToUnicode CMap
(runs under `cargo test`), and `scripts/verify_pdf_umlauts.sh` is an
on-demand cross-check that extracts the PDF text with the third-party `unpdf`
(PDF.js) reader and asserts all seven German umlauts (`ä ö ü ß Ä Ö Ü`)
survive. The script self-skips (exit 0) when Node.js/npm are unavailable.

## 8. Cross-compilation

The dependency tree is pure Rust (no OpenSSL, no C TLS stack), which
keeps cross-builds simple. Supported platforms: **Linux, macOS and
Windows on x86_64 and aarch64**.

```sh
rustup target add x86_64-unknown-linux-musl
cargo build --release --target x86_64-unknown-linux-musl
```

House convention (ndaal): use **cargo-zigbuild** for cross-linking,
which removes the need for a per-target C cross toolchain:

```sh
cargo install cargo-zigbuild   # needs zig on PATH
cargo zigbuild --release --target aarch64-unknown-linux-gnu
cargo zigbuild --release --target x86_64-unknown-linux-musl
```

Typical target triples:

| Platform | Targets |
| --- | --- |
| Linux (glibc) | `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu` |
| Linux (musl) | `x86_64-unknown-linux-musl`, `aarch64-unknown-linux-musl` |
| macOS | `x86_64-apple-darwin`, `aarch64-apple-darwin` |
| Windows | `x86_64-pc-windows-msvc`, `aarch64-pc-windows-msvc` |

Notes:

- musl targets produce fully static Linux binaries — combined with
  the embedded data this yields a copy-anywhere artefact.
- Apple targets are best built on macOS (SDK licensing); Windows MSVC
  targets on Windows or with `cargo-xwin`.
- The embedded catalog is identical on every platform; only the
  platform shown on `/system-info` differs.

## 9. Licensing obligations when redistributing the binary

The compiled binary aggregates several works. When you ship it (or a
container image containing it), the following must accompany the
distribution:

- **Viewer code** — Apache-2.0. Keep `LICENSE`; state changes if you
  modified the source.
- **BSI Grundschutz++ catalog** (embedded) — CC-BY-SA-4.0.
  Attribution to the BSI, license notice, ShareAlike for data
  modifications (see section 5.2).
- **Bootstrap 5.3.3 and Bootstrap Icons 1.11.3** (embedded) — MIT.
  Keep the copyright and permission notice (© The Bootstrap Authors).
- **htmx 2.0.4** (embedded) — 0BSD. No notice required (kept anyway
  via REUSE).
- **Roboto fonts** (embedded) — Apache-2.0. Keep the license notice
  (© Google LLC).
- **Roboto Mono fonts** (embedded) — OFL-1.1. Keep the OFL notice;
  the fonts must not be sold standalone.

Practical checklist for a redistribution bundle:

1. Ship the `LICENSE` file (Apache-2.0, viewer code).
2. Ship the `LICENSES/` directory — it contains the complete texts of
   every license above (`Apache-2.0.txt`, `CC-BY-SA-4.0.txt`,
   `MIT.txt`, `0BSD.txt`, `OFL-1.1.txt`).
3. Ship `data/grundschutz-plus-plus-catalog.json.license` (the BSI
   attribution + retrieval record) or reproduce its contents in your
   distribution notes.
4. The repository is REUSE-structured: every vendored asset under
   `src/static/` carries a `.license` sidecar — keep them when
   redistributing the *source*.

The binary itself also embeds and displays the Apache-2.0 text and the
catalog license record on its `/license` page (Info menu), so end
users of a bare binary can always retrieve the notices — but this does
not replace shipping the license texts alongside a formal
distribution.

## 10. Setting up the malware scanners (ClamAV + YARA-X)

The upload scan (§2.4) and the startup template import (§2.5) shell out to two
external tools, **`clamscan`** (ClamAV) and **`yr`** (YARA-X). They are
**optional**: the scan is *fail-open*, so when a tool is missing the viewer
still runs and stores clean content — but nothing is actually screened for
malware. Install both to get real detection. They must be reachable on the
**same `PATH` the viewer process sees** (a `systemd` unit, for example, has a
minimal `PATH`; set it explicitly — see §3.4).

Each tool needs a binary **and** its data:

| Tool | Binary | Data it needs |
| --- | --- | --- |
| ClamAV | `clamscan` | a **virus database**, downloaded with `freshclam` |
| YARA-X | `yr` | **YARA rules** — the viewer auto-fetches YARA Forge on first start (§2.4) |

ClamAV without a current virus database cannot detect anything (`clamscan`
errors out and that pass is treated as clean), so **always run `freshclam`**
after installing it.

### 10.1 Linux

```sh
# --- ClamAV: clamscan + the freshclam updater ---
# Debian / Ubuntu
sudo apt-get update && sudo apt-get install -y clamav clamav-freshclam
# Fedora / RHEL / Rocky
sudo dnf install -y clamav clamav-update
# Arch
sudo pacman -S --needed clamav

# Download the virus database (stop the auto-updater first if it is running):
sudo systemctl stop clamav-freshclam 2>/dev/null || true
sudo freshclam

# --- YARA-X: the `yr` CLI (needs a Rust toolchain) ---
cargo install yara-x-cli            # installs ~/.cargo/bin/yr
# …or download a prebuilt `yr` for your architecture from
#   https://github.com/VirusTotal/yara-x/releases  and put it on PATH
```

### 10.2 macOS (Homebrew)

```sh
# --- ClamAV ---
brew install clamav
# Homebrew ships no config; create freshclam.conf and drop the "Example" line:
conf="$(brew --prefix)/etc/clamav"
cp "$conf/freshclam.conf.sample" "$conf/freshclam.conf"
sed -i '' '/^Example/d' "$conf/freshclam.conf"
freshclam                           # download the virus database

# --- YARA-X (provides `yr`) ---
brew install yara-x                 # or: cargo install yara-x-cli
```

### 10.3 Windows

**With [Chocolatey](https://chocolatey.org/) (run the shell as Administrator):**

```powershell
choco install clamav -y     # installs clamscan + freshclam and adds them to PATH
freshclam                   # download the virus database
cargo install yara-x-cli    # provides yr  (Chocolatey has no yara-x package; its
                            # `yara` package is the classic engine, not the yr CLI)
```

**Or install manually:**

1. **ClamAV** — download the Windows installer from
   <https://www.clamav.net/downloads> and run it. In the install directory,
   copy `conf_examples\freshclam.conf.sample` to `freshclam.conf`, delete the
   `Example` line, then run `freshclam.exe` to download the virus database.
2. **YARA-X** — either `cargo install yara-x-cli` (with a Rust toolchain) or
   download a `yr` build for Windows from the
   [YARA-X releases](https://github.com/VirusTotal/yara-x/releases).
3. **PATH** — add the folders holding `clamscan.exe` and `yr.exe` to the system
   `PATH` (System Properties → Environment Variables), then start the viewer
   from a **new** terminal so it inherits the updated `PATH`.

### 10.4 Verify it works

```sh
clamscan --version        # e.g. ClamAV 1.5.2
yr --version              # e.g. yara-x-cli 1.17.0
```

Start the viewer with annotations enabled (`--db …`); the startup log confirms
what is active:

```text
INFO upload scanning enabled: ClamAV (clamscan) + YARA (yr) rules=…/yara-rules
```

End-to-end check with the **EICAR** test string — the standard, harmless
antivirus test file (<https://www.eicar.org/download-anti-malware-testfile/>).
The repository ships `templates/QS-Checkliste_mit_Emoji_with_EICAR.md` as an
example; with the scanners working, the startup template import detects and
skips it:

```text
WARN annotation template skipped template=QS-Checkliste_mit_Emoji_with_EICAR.md reason=malware scan: YARA: …
INFO reusable annotation templates imported imported=5 skipped=1
```

Uploading a file that contains the EICAR string is likewise rejected with HTTP
`422`. Keep ClamAV signatures current (`freshclam`, or its service/timer); the
YARA rules are cached after the first fetch — delete the rules directory or
re-run `scripts/fetch_yara_rules.sh` to refresh them (§2.4).

### 10.5 Offline / air-gapped ClamAV database

On a host that cannot reach the ClamAV mirrors, ship the virus database
out-of-band instead of relying on `freshclam`. Every release carries a
`clamav-db-<date>.tar.zst` asset (plus a `.sha256` sidecar), built by
`scripts/update_clamav_db.sh` on a connected machine:

```sh
# On a connected machine — build the artifact (writes into release/ by default):
scripts/update_clamav_db.sh        # → clamav-db-2026-06-24.tar.zst (+ .sha256)
```

Move both files to the air-gapped host, verify, extract, and point the viewer
at the extracted directory:

```sh
# verify the checksum, then unpack the signature directory
shasum -a 256 -c clamav-db-2026-06-24.tar.zst.sha256     # Linux: sha256sum -c
mkdir -p /srv/clamav-db
tar --use-compress-program=unzstd -xf clamav-db-2026-06-24.tar.zst -C /srv/clamav-db

# run the viewer against the bundled database
grundschutz-oscal-viewer --db notes.db --clamav-db-dir /srv/clamav-db
```

The startup log confirms the database is in use:

```text
INFO ClamAV using bundled/refreshed signature DB db=/srv/clamav-db
```

Where the host *does* get occasional network access, skip the manual extract
and let the viewer refresh the directory with `freshclam` at start-up instead:

```sh
grundschutz-oscal-viewer --db notes.db \
  --clamav-db-dir /srv/clamav-db --update-clamav-db
```

A missing or empty signature directory is not fatal: `clamscan` falls back to
the system database, and if that is absent too the ClamAV pass is skipped
(fail-open — YARA still runs). To confirm detection, scan the EICAR sample as
in §10.4; with a loaded ClamAV database the finding reads `ClamAV: …` rather
than `YARA: …`. The bundled database is ~300 MB and changes daily, so rebuild
and redistribute the artifact regularly; it is deliberately **not** embedded in
the binary.

## 11. Backing up the annotation database

Everything users create — annotations, their full version history, uploaded
image assets, and the imported template set — lives in the single SQLite
**annotation database** (`--db`, default
`<home>/grundschutz-oscal-viewer/annotations.db`). The catalog itself is
embedded in the binary and never needs backing up; back up the annotation
database to protect user data.

The database runs in **WAL** mode, so copying only the `.db` file can miss
recent writes still in the `-wal` sidecar. Use one of the two safe methods
below.

### 11.1 Online backup (viewer running) — recommended

`sqlite3`'s online backup takes a transaction-consistent snapshot while the
viewer keeps running:

```sh
db="$HOME/grundschutz-oscal-viewer/annotations.db"
out="annotations-$(date +%Y%m%d-%H%M%S).db"
sqlite3 "$db" ".backup '$out'"
# equivalent, and compacts the copy:
sqlite3 "$db" "VACUUM INTO '$out'"
```

The result is one self-contained `.db` file (no `-wal`/`-shm` needed). Verify
it before relying on it:

```sh
sqlite3 "$out" "PRAGMA integrity_check;"      # expect: ok
sqlite3 "$out" "SELECT COUNT(*) FROM annotations;"
```

### 11.2 Cold backup (viewer stopped)

Stop the viewer first — a clean `SIGTERM` checkpoints the WAL into the main file
(§3.4 / §3.5) — then copy it:

```sh
pkill -TERM -f grundschutz-oscal-viewer       # or stop your service unit
cp "$db" annotations-backup.db
# if no checkpoint ran, copy the sidecars too:
cp "$db"-wal "$db"-shm . 2>/dev/null || true
```

On Windows, stop the process/service and copy `annotations.db` (and any
`annotations.db-wal` / `annotations.db-shm`) with File Explorer or `Copy-Item`.

### 11.3 Restoring

Stop the viewer, replace the database with the backup, remove any stale
sidecars, then start again:

```sh
pkill -TERM -f grundschutz-oscal-viewer
rm -f "$db"-wal "$db"-shm
cp annotations-backup.db "$db"
grundschutz-oscal-viewer --db "$db"
```

### 11.4 Automating it

Schedule the online backup (safe while the viewer runs) and keep copies **off
the host**:

```sh
# cron (Linux/macOS): daily at 02:30
30 2 * * *  sqlite3 ~/grundschutz-oscal-viewer/annotations.db \
  "VACUUM INTO '/srv/backups/annotations-$(date +\%Y\%m\%d).db'"
```

On Windows, run the same `sqlite3 … VACUUM INTO` command from Task Scheduler.
Because each backup is a plain file, fold it into your normal file-backup /
off-site rotation. It can contain sensitive notes, so protect it with the same
access controls (and, ideally, encryption at rest) as any confidential document.

### 11.5 Encryption at rest (`--encryption-key-file`)

By default the annotation database stores bodies and uploaded image assets as
plaintext, so anyone who can read the `.db` file — or any `.db` / `.sql` /
`.sql.gz` dump of it — can read the notes. To keep the data confidential **on
disk**, point the viewer at a key file:

```sh
# generate a 32-byte key (64 hex characters) and lock it down
umask 077
openssl rand -hex 32 > ~/grundschutz-oscal-viewer/annotations.key
chmod 600 ~/grundschutz-oscal-viewer/annotations.key

grundschutz-oscal-viewer --encryption-key-file ~/grundschutz-oscal-viewer/annotations.key
# or: GSV_ENCRYPTION_KEY_FILE=~/grundschutz-oscal-viewer/annotations.key grundschutz-oscal-viewer
```

The key file holds the key as **32 raw bytes** or **64 hexadecimal characters**
(surrounding whitespace is trimmed). With it set, annotation bodies and image
bytes are sealed with **XChaCha20-Poly1305** (authenticated encryption; a fresh
random 24-byte nonce per value) before they reach SQLite and opened again on
read. The work is transparent: the web UI, search, export and dumps all behave
exactly as before — a dump of an encrypted store simply contains ciphertext.

Operational notes:

- **No migration needed.** Plaintext and encrypted rows coexist via a
  self-describing marker, so you can enable the key on an existing database; old
  rows stay readable and every new write is sealed. (To encrypt the *existing*
  rows too, re-save those annotations after enabling the key.)
- **Losing the key loses the data.** There is no recovery — the key is never
  written to the database. Back up the key file **separately** from the database
  backups (§11.1–11.4); if they are stored together, the encryption protects
  nothing. A wrong key never decrypts: affected values come back sealed, not as
  plaintext.
- **Fail-closed start-up.** If `--encryption-key-file` is set but the file is
  missing or malformed, the viewer exits with an error instead of silently
  falling back to plaintext storage.
- **Metadata is not encrypted.** Only the body text and asset bytes are sealed;
  target references, revision numbers, modal/maturity/status, filenames and
  timestamps remain in the clear for indexing. Combine encryption-at-rest
  with the filesystem access controls in §11.4 for defence in depth.

#### 11.5.1 Rotating the encryption key

Give the viewer the new key *and* the old one. The old key is used for reading
only, so already-sealed values keep opening while every new write uses the new
key:

```sh
umask 077
openssl rand -hex 32 > ~/grundschutz-oscal-viewer/annotations.key.new
chmod 600 ~/grundschutz-oscal-viewer/annotations.key.new

grundschutz-oscal-viewer \
    --encryption-key-file         ~/grundschutz-oscal-viewer/annotations.key.new \
    --retired-encryption-key-file ~/grundschutz-oscal-viewer/annotations.key
# env equivalents: GSV_ENCRYPTION_KEY_FILE, GSV_RETIRED_ENCRYPTION_KEY_FILE
```

There is **no migration step and no change to stored data** — the on-disk format
is unchanged, so this is safe to try and safe to roll back (swap the two flags).

Three things to be clear about before you rely on it:

- **Do not skip the retired flag.** Starting with only the new key leaves every
  previously sealed body and image unreadable, and it fails *silently*: an
  unopenable value is returned verbatim, which is indistinguishable from a note
  that always was plaintext. Verify by opening a pre-rotation annotation in the
  UI before you delete anything.
- **Rotation is not revocation.** The retired key can still read until the data
  is re-sealed. If you are rotating because the old key may have leaked, follow
  up by re-saving the affected annotations (which rewrites them under the new
  key), then restart *without* `--retired-encryption-key-file` and confirm the
  data still opens. Only then is the old key inert.
- **The retired key is fail-closed too.** A missing or malformed retired key
  file aborts start-up rather than starting up half-readable.

One retired key is supported per process, which covers one rotation at a time.
Complete the re-seal step above before rotating again, or the generation before
last becomes unreadable.

## 12. Quick reference

```sh
# build
cargo build --release

# run (localhost:8228)
grundschutz-oscal-viewer

# custom port / bind (flags beat env vars)
grundschutz-oscal-viewer --port 9000
GSV_BIND=0.0.0.0 GSV_PORT=8080 grundschutz-oscal-viewer   # see §3.2!

# Meilisearch
grundschutz-oscal-viewer --meili-url http://localhost:7700 --meili-key <key>

# encryption-at-rest for annotation bodies + image assets (§11.5)
grundschutz-oscal-viewer --encryption-key-file ~/grundschutz-oscal-viewer/annotations.key

# offline / air-gapped ClamAV signature database (§10.5)
grundschutz-oscal-viewer --db notes.db --clamav-db-dir /srv/clamav-db

# export the embedded catalog (refuses to overwrite)
grundschutz-oscal-viewer --export /tmp

# refresh catalog + rebuild
scripts/update_catalog.sh && cargo build --release

# full QA gate set
just qa
```