<!-- 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
```