Skip to main content

Changelog

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

# Changelog

All notable changes to the BSI Grundschutz++ OSCAL Viewer are documented
in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.6.54] - 2026-08-19

### Security

- **Error responses disclosed internal detail to unauthenticated callers**
  (CSAF `ndaal-sa-2026-415`, CWE-209, ASVS V16.2, threat model W-I1; CVSS
  v3.1 5.3 / v4.0 6.9 MEDIUM). Six handlers interpolated the underlying
  error into the body they returned — annotation save, image upload, asset
  read and template read (HTTP 500), the dump download, and the `/readyz`
  probe (HTTP 503) — so provoking a failure disclosed SQLite messages and
  the absolute `--db` / `--dump-dir` paths. The readiness probe was the
  most exposed of the six: unauthenticated by definition and commonly
  polled. No annotation content, image asset or key material was ever
  reachable this way. All six now route through a single
  `router::logged_error(status, context, error)`, which sends the cause to
  `tracing::error!` and returns only the fixed label; the invariant is
  pinned by `logged_error_keeps_the_underlying_cause_out_of_the_body`.

- **Database restore accepted unverified dumps and replayed SQL with
  unrestricted engine authority** (CSAF `ndaal-sa-2026-416`, CWE-345 with
  CWE-501, threat model S-T3/S-E1; CVSS v3.1 6.6 / v4.0 6.8 MEDIUM).
  `--restore-dump` validated the SQLite magic, UTF-8 and size caps but
  never checked the five checksum sidecars its own dump writer emits, so
  anyone able to write to a dump file could have a later restore present a
  rewritten annotation history as authentic. The `.sql` path additionally
  ran through `execute_batch` with full engine authority: a hostile dump
  could `ATTACH` a second database, or leave triggers and views behind
  that fire on every later annotation write.

  Both halves are closed. `restore_dump` now calls the new
  `sidecar::verify_sidecars`, re-deriving every digest whose sidecar
  exists and refusing on mismatch — and refusing a source with **no**
  sidecar at all, since whoever can rewrite a dump can equally delete its
  sidecars. `Store::restore_sql_dump` sets `SQLITE_LIMIT_ATTACHED = 0`
  (rusqlite `limits` feature), so `ATTACH` fails inside SQLite rather than
  being filtered as text, and a post-replay check refuses any trigger or
  view the dump left behind — the executable schema — while requiring the
  four annotation tables to be present (bare extra tables remain allowed:
  they execute nothing).

  A first `fuzz_verify_sidecars` run then caught a contract mismatch in the
  new verifier itself: a sidecar that existed but was not readable UTF-8 (a
  single `0xFF` byte) was silently skipped as if absent, contradicting the
  function's own documented error contract. Present-but-unreadable is now a
  refusal; only a genuinely missing sidecar is skipped. The restore was
  fail-closed either way — an unreadable sidecar left the verified count at
  zero, which the caller already refuses — so this hardens the contract
  rather than closing a second hole.

  **Operator-visible change:** a dump with no checksum sidecar beside it
  is now refused. Dumps produced by the viewer carry all five and restore
  unchanged; for a hand-made dump, generate one first, e.g.
  `shasum -a 256 dump.sql > dump.sql.sha-256`.

- **Encryption key rotation was a data-loss event** (CSAF
  `ndaal-sa-2026-417`, CWE-392, threat model C-D1; CVSS v3.1 4.4 / v4.0 6.7
  MEDIUM).
  `Cipher` held exactly one key and the `enc:v1:`/`ENC1` envelope records no
  key identity, so pointing `--encryption-key-file` at a new key turned every
  previously sealed annotation body and image asset into the
  verbatim-ciphertext fallback — silently, because a wrong key is
  indistinguishable from stored plaintext by design. Operators therefore had
  no way to rotate a key at all, which is why the cryptographic inventory
  listed rotation as "not supported".

  A cipher can now hold a sealing key plus any number of retired,
  opening-only keys (`Cipher::from_keys`), exposed as
  `--retired-encryption-key-file`. Opening tries each held key in turn,
  primary first; Poly1305 rejects a wrong key, so **no key ID and no new
  envelope version were required** and the stored format is byte-identical —
  rotation runs no migration and cannot corrupt an existing store. Pinned by
  `rotating_the_key_keeps_values_sealed_by_the_retired_key_readable`,
  `every_generation_of_retired_key_is_tried_not_only_the_first` (an
  implementation consulting only the first retired entry passes the former
  and fails the latter) and `from_keys_with_no_retired_keys_matches_from_key`.

  **Scope, stated precisely:** this makes rotation survivable, not
  revoking. A retired key can still read until its data is re-sealed, so
  rotation alone does not contain a compromised key; a re-encryption sweep
  remains outstanding and is tracked as the reduced C-D1 residual. The one
  new side channel is timing — a value sealed under a later-listed key takes
  marginally longer to open, revealing roughly which generation it belongs
  to. That is not secret, so it does not justify constant-time trial
  decryption.

### Security

- **`h2` 0.4.15 → 0.4.16 (RUSTSEC-2026-0258, low severity).** The crate
  accepted and queued empty HTTP/2 DATA frames without limit; a stream that
  was not actively drained could grow memory without bound, or panic if the
  length overflowed. `h2` reaches the shipped binary through `hyper` — the
  HTTP server itself — not only through the dev-dependency used by the
  HTTP/2 settings tests, so the runtime path was affected.

  Caught by the `cargo-deny` advisories gate inside
  `create_release_on_crates.io.sh`, which refused to publish. Worth recording
  precisely: the earlier `cargo publish --dry-run` had passed, because the
  dry-run exercises packaging and the verify build but NOT the cargo-deny
  gate. A clean dry-run is evidence that the crate packages, not that it is
  publishable.

### Added

- **`/about` now says where to get the source.** The Info menu's About page
  gains a *Source code* card linking the project repository and the crate's
  crates.io page, plus the `cargo install` line. Someone holding only the
  binary previously had no in-product route back to the source — the existing
  *Data source* card covers the BSI catalog, not the viewer itself. Both URLs
  are derived from `[package]` at compile time (`CARGO_PKG_REPOSITORY` /
  `CARGO_PKG_NAME`) rather than written into the template, so they cannot
  drift from `Cargo.toml`; pinned by
  `about_page_links_to_the_source_repository_and_crates_io`, which checks the
  manifest and the compiled-in values agree *and* that the page renders them
  as real links.
- **STRIDE threat model** at `documentation/threat-model/stride.md`:
  per-boundary analysis across all six STRIDE categories for the web
  surface, upload pipeline, storage/export, cryptography, outbound
  fetchers and supply chain, every mitigation cited to the enforcing
  code, plus a normative data-classification / encryption-at-rest
  policy and a residual-risk register. Companion Microsoft Threat
  Modeling Tool file `grundschutz-oscal-viewer.tm7`, regenerated
  deterministically by `scripts/generate_threat_model_tm7.py`.
- **Cryptographic inventory** (ASVS v5.0.0 V11.1) at
  `documentation/security/cryptographic-inventory.md`: every
  primitive, its parameters, randomness sources, the key lifecycle
  (including the documented no-rotation limitation), approved-
  algorithm statement and post-quantum posture.
- **Validation architecture** (ASVS v5.0.0 V2.1) at
  `documentation/security/validation-architecture.md`: the ordered
  request pipeline (TLS → host guard → route → body bound →
  same-origin → typed parse → domain rules) and the per-input
  validation catalog, with known deviations listed instead of hidden.

### Changed

- **Local spelling correction applied to BSI source data — a deliberate
  divergence from upstream.** `Infomationssicherheitseinstufung` →
  `Informationssicherheitseinstufung` (a missing `r` in GC.7.1's title) in
  three files: the change overlay's previous-edition snapshot
  (`data/grundschutz-plus-plus-changes.json`) and both vendored reference
  catalogs under
  `skills/bsi-grundschutz/references/oscal-grundschutz-plus-plus/`.

  Recorded here because of what it means for provenance, not because of its
  size. The typo is **BSI's own**, and BSI had already fixed it in the
  2026-08-13 edition — the current catalog spells it correctly throughout. The
  occurrence in the overlay was therefore a faithful record of what the
  2026-07-29 edition actually said, and correcting it means the overlay no
  longer reproduces the previous edition verbatim: GC.7.1 is still tagged
  *Updated*, but its previous and current titles are now identical, so the
  page shows no visible difference for it. The vendored catalogs likewise no
  longer match their upstream CC-BY-SA source byte-for-byte.

  Two consequences to expect. Regenerating the overlay with
  `scripts/diff_catalog.py` against the committed catalog will reintroduce the
  original spelling, since it re-derives `previous` from real data. And the
  Bruno fixture that proved old/new content renders side by side had to move
  off GC.7.1 (whose title diff this edit erased) to GC.4.2, whose recorded
  change is a guidance correction and is not vulnerable to the same class of
  edit.
- **BSI Grundschutz++ catalog refreshed to 2026-08-13** (from 2026-07-29):
  **11 controls modified, 0 added, 0 removed** — the control and practice
  counts are unchanged, so nothing downstream had to be recounted. The
  changes overlay is regenerated (baselined against the 2026-07-29 edition,
  so those 11 render as **Updated** with their previous content available for
  comparison) and both `.license` sidecars record the 2026-08-14 retrieval.

  The modified controls cluster tightly in governance and compliance rather
  than being scattered churn, which reads as a deliberate editorial revision
  of the legal/contractual-obligations material: the whole `GC.3.1`
  subtree — *Verfahren und Regelungen* plus its four children (*Gesetzliche
  Verpflichtungen*, *Anhörung zuständiger Stellen*, *Vertragliche
  Verpflichtungen*, *Prävention von Verstößen*) — together with `GC.4.2`
  (*Analyse der internen interessierten Parteien*), `GC.7.1` (*Vorgehen bei
  der Informationssicherheitseinstufung*), `NOT.1.1.4` (*Business Continuity
  Management System*), and `UMS.2.1` / `UMS.2.2` / `UMS.7.1`
  (*Umsetzungsplanung*, *Priorisierung von Maßnahmen*, *Wahrung von
  Compliance in der Umsetzung*).

  The upstream file differs from the previous edition by only two bytes and
  keeps the same control count, so a raw-JSON comparison is worthless here —
  BSI rewrites `metadata.version`, `last-modified` and the `links` UUID on
  every republish. The 11 is the count from `diff_catalog.py`'s substantive
  projection (title, statement, guidance, modal verb, security level, effort,
  tags, params); raw equality would have reported most of the catalog as
  changed.

- Dependency currency sweep (CSAF ndaal-sa-2026-409..414): `base64`
  0.22.1 → 0.23.1 (requirement raised to `0.23`; the sole remaining
  0.22 user is `pem` via `rcgen`, upstream-blocked until rcgen
  supports pem 4), `blake3` 1.8.6, `rusqlite` 0.40.2 (+
  `libsqlite3-sys` 0.38.2), `http-body-util` 0.1.5, `rcgen` 0.14.9,
  and `ureq` 3.4.0 (+ `ureq-proto` 0.6.1) which moves the
  self-update path onto the base64 0.23 line.

- **`base64` 0.23 reverses the deliberate hold recorded in 1.6.53**, and the
  cost is a relaxed lint. That entry kept base64 at 0.22 precisely because
  `pem` (via `rcgen`) and `ureq`/`ureq-proto` (via `self_update`) still
  required it, so bumping would introduce a duplicate version and trip
  `clippy::multiple_crate_versions` under the gate's `-D warnings`.
  Advancing ureq to 3.4.0 removed two of the three 0.22 dependents; the last,
  `pem 3.x` via `rcgen`, is upstream-blocked (pem adopted 0.23 only in 4.0.0,
  and rcgen 0.14.9 still requires `pem ^3.0.2`). To keep the bump and a green
  gate, `multiple_crate_versions` is now `"allow"` rather than `"warn"` in
  `Cargo.toml`, with the exit condition written beside it: restore `"warn"`
  once rcgen supports pem 4. The duplicate is inert — pem uses base64 only to
  (de)serialise the development TLS certificate — and duplicate-version policy
  still lives in `cargo-deny`, but this is a weakened check and is recorded as
  such rather than presented as a clean upgrade.

### Testing

Verification for the key rotation and export paths this release ships. Each
item below was checked to actually FAIL when the behaviour it guards is broken
— a test that passes immediately proves nothing, so the mutation used is
recorded alongside it.

- **Property tests for key rotation.** All four pre-existing `crypto`
  properties used single-key `Cipher::from_key`; nothing exercised
  `from_keys`, so the rotation chain had example-based unit tests and no
  property coverage. Four properties now cover arbitrary rotation depth and
  arbitrary bodies: any held generation opens, `seal` is write-primary (a
  retired-only cipher must NOT open a fresh seal), an unheld generation never
  opens, and the blob path behaves like the text path. Teeth verified: a
  `.take(1)` mutant — only the FIRST retired generation tried, a plausible
  off-by-one — failed two of them, with proptest shrinking to a minimal input.
  The seeds that mutant produced were deliberately NOT committed to
  `proptest-regressions`: that file records inputs which once failed a REAL
  bug, and pinning artificial ones would imply a defect that never existed.

- **Loom model E — the export render lock nests inside the heavy-op permit.**
  Model D already covered the `heavy_ops()` permit pool alone; the NESTING was
  unmodelled, and that is where the property lives. `/export` takes a
  cap-2 permit, then `spawn_blocking`, then the process-wide `RENDER_LOCK`
  that exists because `lo_writer::save_as` does a temp-file dance two
  concurrent renders would collide on. Model E proves the render is strictly
  serial, the permit bound holds while nested, nothing leaks, and — because
  loom fails a model that cannot progress — pins the permit-then-lock
  acquisition order. Teeth verified: removing the guard fails it with
  "two renders overlapped", demonstrating the cap-2 permit alone DOES admit
  two renders, so both primitives are load-bearing.

- **Criterion benchmarks for the crypto paths.** The existing
  `criterion-benches/` crate covered catalog parse, filter, search and query
  decode, and no crypto or markdown. Added seal/open, markdown render, and
  `crypto_open_rotation_depth` at 0/1/3/5 retired keys. That last one measures
  the cost of the rotation trade: `open` tries each held generation in turn,
  so reading a value sealed with the OLDEST key costs one failed AEAD
  authentication per newer generation, making read cost O(generations held).
  The delta between 0 and 5 IS the per-generation read tax.

- **New `callgrind-harness/` crate** for instruction-count benchmarking
  (iai-callgrind, pinned exactly at `=0.16.1`). Instruction counts barely vary
  between runs, so they gate on a noisy CI runner where wall-clock numbers
  cannot. Standalone crate for the same reason as `loom-harness/`: a
  `[[bench]]` inside the main crate would be built and run by
  `cargo test --workspace --all-targets`, which would then shell out to
  Valgrind and fail on any machine without it.

  **These benchmarks have never been executed.** Valgrind's Homebrew formula
  is `depends_on :linux`, so they cannot run on the macOS host that wrote
  them. They type-check only; whether the numbers are sensible, and whether
  the fixture setup genuinely stays outside the measured region, is unverified
  until Linux CI runs them.

- **Coverage floor raised 80 → 85** in `tests/scripts/test_cargo_tarpaulin.sh`,
  with the measured baseline recorded: **90.19% (4780/5300 lines)**. The old
  default contradicted both CLAUDE.md and `skills/rust-tarpaulin`, and the
  script's own comment instructed ratcheting it up once the baseline cleared.

- **Playwright retries enabled locally** (`retries: CI ? 2 : 0` → `2`). A full
  153-test matrix produced 137 passed / 16 failed, and every one of the 16 was
  a TIMEOUT under host contention — zero assertion, axe or screenshot
  failures. With no local retry buffer a single transient stall was recorded
  as a hard failure that CI would have retried green, which makes a local red
  unreadable. Retrying does not mask real defects: a genuine assertion failure
  is deterministic and fails all three attempts.

- **Change-tracking fixtures repinned to the 2026-08-13 catalog edition**
  across all five surfaces that held the stale ids (routes, BDD inner, BDD
  feature + steps, Playwright). GC.2.2 and BER.5.1.1 no longer carry change
  records in that edition. The BER.5.1.1 assertion is now INVERTED into a
  stale-tag guard — a test that only ever asserted "New appears" could not
  fail when the badge outlived its edition.

## [1.6.53] - 2026-08-05

### Security

- **A non-ASCII `Host` header bypassed the anti-DNS-rebinding guard.**
  `host_guard::host_allowed` read the header with
  `.and_then(|v| v.to_str().ok())`, which collapses two very different states
  into `None`: "no authority was supplied" and "an authority was supplied but
  is not readable as ASCII". The first is legitimately allowed — a request
  with no `Host` cannot be a rebinding target — so a request that *did* carry
  a `Host` header took the allow-by-default path whenever that header held
  bytes in 0x80-0xFF. `HeaderValue` accepts those bytes; `HeaderValue::to_str`
  rejects them. Reading the authority now yields an explicit
  `Present` / `Absent` / `Unreadable`, and `Unreadable` is refused: a value
  that cannot be compared against the allow set must not be trusted.

  Found by `cargo fuzz` (`fuzz_host_allowed`, crash input `2a 3b da aa`,
  i.e. `*;\u{6AA}`) on the first release run that actually executed the
  verification gate. The exact crash artifact is pinned as a regression test,
  `a_present_but_unreadable_host_header_is_refused`.

  Exploitability is limited — a browser sends IDN hosts as ASCII punycode, so
  a real rebinding attack has no reason to emit a non-ASCII `Host` — but the
  guard's documented contract ("a present authority must be the bind
  authority, a loopback alias, or one of `extra`") did not hold, and this is
  the chokepoint every mutating request passes through.

  Published as CSAF advisory
  [`ndaal-sa-2026-382`](csaf/2026/382/ndaal-sa-2026-382.json): CVSS v3.1 **4.2
  (MEDIUM)** `AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N`, CVSS v4.0 **2.1 (LOW)**
  `AV:N/AC:H/AT:P/PR:N/UI:A/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N`, CWE-346 (Origin
  Validation Error). No CVE requested.

- **The same conflation in the CSRF gate: an unreadable `Sec-Fetch-Site` read
  as absent.** `routes::annotation::same_origin` also used `.to_str().ok()`,
  so a `Sec-Fetch-Site` header that was *present* but held bytes in 0x80-0xFF
  fell through to the weaker `Origin`/`Host` comparison — an arm an attacker
  controlling both of those headers can satisfy. The header now decides the
  request outright: unreadable means refused, because a real browser always
  sends an ASCII token here and anything else is anomalous.

  Found by `fuzz_same_origin` in a full 76-target sweep run *because* of the
  `host_guard` fix — having identified the pattern once, the same shape was
  worth hunting for elsewhere. Practical exposure is lower than the
  `host_guard` case (a browser sets `Sec-Fetch-Site` itself, and a cross-site
  page cannot forge it), but the gate's stated contract — that a present
  `Sec-Fetch-Site` is the exact decider — did not hold. Pinned by
  `same_origin_refuses_a_present_but_unreadable_sec_fetch_site`.

  Published as CSAF advisory
  [`ndaal-sa-2026-383`](csaf/2026/383/ndaal-sa-2026-383.json): CVSS v3.1 **3.1
  (LOW)** `AV:N/AC:H/PR:N/UI:R/S:U/C:N/I:L/A:N`, CVSS v4.0 **2.1 (LOW)**
  `AV:N/AC:H/AT:P/PR:N/UI:A/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N`, CWE-346.

### Tests

- **Line coverage measured at 90.17% (4 732 / 5 248 lines), confirming the
  estimate published in 1.6.52.** That entry projected "~90%" from a line
  count, not from a completed run: the run meant to confirm it aborted when
  `CHANGELOG.md` was edited while tarpaulin was still executing, and
  `dialog.rs`'s `changelog_embed_is_in_sync_with_file` correctly failed on the
  drift between the compiled `include_str!` snapshot and the file on disk —
  the test doing exactly its documented job. A clean run against a frozen tree
  reports 90.17%, +2.76 points over the 87.40% baseline, clearing the blocking
  `--fail-under 80` gate by 10.17 points. All 22 suites are green: 488 lib
  tests, 90 proptest properties, 14 BDD scenarios / 43 steps, zero failures.
- **Excluding `src/main.rs`, the library is at 97.48% (4 675 / 4 796)** — not
  the "~94%" estimated in 1.6.52, which was also conservative. `main.rs` is
  57/452 by itself and holds 395 of the 516 uncovered lines (socket binding,
  TLS listener setup, `process::exit`). The remaining gaps are small and
  concentrated: `template_response.rs` at 5/8 and `routes/health.rs` at 6/8.

- **`fuzz_render_markdown` no longer reports an intentionally-handled panic as
  a crash.** markdown-rs 1.0.0 panics on some setext-heading inputs
  (`to_html.rs:197`), which `render_markdown` has always absorbed via
  `catch_unwind`, degrading to inert escaped text. But `libfuzzer-sys` installs
  a panic hook that aborts the process the moment a panic is *raised* — before
  unwinding — so the handled case still killed the fuzz run and blocked the
  release. Verified directly that the reported input returns `<p>…</p>` inert
  HTML from `render_markdown`; the harness now installs a silent hook once per
  process. This does not weaken the target: a panic that genuinely escapes
  still unwinds across libFuzzer's `extern "C"` boundary and aborts, confirmed
  by injecting a probe panic and observing `deadly signal` / exit 77.
  The reproducer is pinned as `markdown_fuzz_found_crasher_degrades_to_inert_text`,
  because `fuzz/corpus/` and `fuzz/artifacts/` are both gitignored and it would
  otherwise not survive a fresh clone.
- **The CSRF same-origin gate now carries proptest invariants (90 -> 93
  properties).** `routes::annotation::same_origin` guards every mutating
  endpoint and had a `fuzz_same_origin` target, but that target only proves the
  function never panics — the security contract itself was unasserted in the
  normal `cargo test` run. Three properties pin it: `Sec-Fetch-Site` decides
  alone and case-sensitively when present (a forged `Origin`/`Host` pair cannot
  talk the gate round); without it, a scheme downgrade to `http://` is refused
  even when the authority matches, an `Origin` with no `Host` is refused rather
  than defaulting open, and the verdict is otherwise exact authority equality;
  and a crafted `http://evil//victim` Origin never passes, which a naive
  `rsplit("//")` comparison would wave through.
- **`self_update` is now guarded against being feature-gated**
  (`tests/self_update_is_mandatory.rs`). `skills/rust-self-update` Rule 1 makes
  the dependency mandatory and un-gated — opting out is the runtime
  `--no-self-update` / `GSV_NO_SELF_UPDATE` policy, not a build variant — but
  nothing enforced it. Adding `optional = true` or a `[features]` gate now
  fails to *compile* this test target rather than silently shipping a binary
  that cannot update itself.

### Fixed

- **The release pipeline had never actually run its verification gate.**
  `scripts/release_pipeline.sh` defaults `RP_SKIP_VERIFY` to **1**, so STEP 1
  (`run_exhaustive_tests.sh`) was skipped on every release to date, 1.6.52
  included. Running it explicitly (`RP_SKIP_VERIFY=0`) surfaced the `Host`
  header bypass above plus four blocking gate failures that had accumulated
  unseen: rustfmt drift across nine `src/` test modules (from the 1.6.52
  coverage work), two clippy errors, a blocking `std::fs::create_dir_all`
  inside an async test helper (`src/main.rs`, present since 1.6.47), and the
  fuzz crash. All are fixed; `rust-doctor` now exits 0 with zero errors and
  zero warnings under the repo's `fail_on = "warning"` policy.
- **`tr_detail` reduced from cyclomatic complexity 21 to a flat lookup.** It
  was a 21-arm `match` over string keys — data, not control flow. It now uses
  the same `from_table(DETAIL_TABLE, key)` idiom as every other `tr_*`
  function in the module, with the table at module scope.
- **Two confirmed rust-doctor false positives annotated in place** (the repo
  forbids blanket suppressions, so each carries its justification):
  `std::thread::sleep` inside a `std::thread::spawn` closure is not an async
  context — the rule itself documents that it "cannot follow calls into other
  functions" — and `Vec::new()` performs no heap allocation at all, so there
  is nothing to hoist out of the loops flagged in test code.
- **`cargo kani` was never actually BLOCKED — it verifies 3/3 proofs.** The
  1.6.52 entry recorded kani as environmentally blocked by `libsqlite3-sys`'s
  use of the unstable `cfg_select` feature. That diagnosis was right about the
  symptom and wrong about the conclusion: the block only hits
  `cargo kani --workspace` at the repo root, which compiles the viewer crate
  and therefore sqlite. `kani-harness/` is a standalone workspace built for
  exactly this reason — it `#[path]`-includes only the rusqlite-free pure
  modules — and the runner simply never used it. Run there, all 3 harnesses
  verify: `paginate_window_is_always_well_formed` (0 of 52 checks failed),
  `paginate_empty_catalog_is_one_empty_page` (0 of 58), and
  `cli_parse_empty_args_is_defaults` (0 of 3009, 265 unreachable).
  `test_cargo_kani.sh` now targets `kani-harness/` when present and reports
  `PASS — 3 harnesses verified`.
- **`test_cargo_kani.sh` ran against the sibling project.** `REPO_ROOT` was
  derived as `${SCRIPT_PATH}/../../..` — one `..` too many for a script at
  `<repo>/tests/scripts/` — so it resolved to the worktrees parent, and
  `QG_WORKSPACE` then appended a hardcoded `/vulnerability-lookup-rs`. The
  run verified the sibling crate and wrote its report into that project's
  tree. Both now derive from `git rev-parse --show-toplevel`, matching
  `test_cargo_tarpaulin.sh`. This is the same class of defect already fixed
  in `test_miri.sh` in 1.6.52.
- **The harness count printed empty, and a passing run reported a warning.**
  Two shell defects in the same script: the proof-count `grep` was handed a
  non-existent `crates/` directory, and under `set -o pipefail` its exit 2
  failed the whole substitution; and `grep -c` prints its count *and* exits 1
  at zero, so `|| echo 0` produced the two-line string `"0\n0"`, which is not
  `-eq 0` — a clean run therefore took the warn branch. The count now filters
  to directories that exist (reporting 3), and the verdict parser uses
  `|| true`.

### Changed

- **README documents the measured coverage figure** and the direct runner
  path (`tests/scripts/test_cargo_tarpaulin.sh`); there is no `just` recipe
  for it. It also now documents the kani model-checking layer, which was
  absent from the README entirely.
- **`self_update` 1.0.0-rc.5 -> 1.0.0-rc.6**, the baseline `skills/rust-self-update`
  requires. Registry-confirmed as the latest of the rc line rather than assumed;
  rc.6's MSRV of 1.88 is already covered by this crate's 1.93. No API change was
  needed (`cargo check --all-targets` clean, 45 updater tests green) and no
  duplicate entered the graph. The requirement stays written out in full,
  because a caret range never resolves a prerelease.
- **`time` 0.3.54 -> 0.3.55** (semver-compatible patch). `base64` 0.22.1 is
  deliberately held at 0.22: 0.23 is a breaking major, and `pem`, `ureq` and
  `ureq-proto` all still pin 0.22, so taking it now would duplicate base64 in
  the dependency tree for no benefit. Those two are the only root dependencies
  behind latest.

## [1.6.52] - 2026-08-03

### Tests

- **Line coverage 18.39% -> ~90%, via 150 qualified unit tests.** The
  `cargo_tarpaulin` gate now passes its blocking `--fail-under 80`; it had
  been failing, and before that five consecutive runs died mid-compile. Two
  distinct effects, kept separate so the jump is not mistaken for tests
  alone: the denominator fell from 23 050 to 5 248 because the coverage run
  now excludes `vendor/` (third-party sources previously counted against
  us), and the numerator rose from the new tests. The lib suite went from
  340 to 488 tests, all passing.
- **The tests target real invariants and error paths, not getters.**
  Examples: `scan_with_fails_open_when_the_temp_file_cannot_be_written`
  (poisons the temp path so `OpenOptions::create_new` fails),
  `dump_download_is_gated_and_resolves_only_names_inside_the_dump_root`,
  `export_submit_sheds_load_when_every_heavy_op_permit_is_taken`,
  `fetch_gives_up_when_the_redirect_budget_is_exhausted`, and
  `ensure_rules_leaves_an_operator_provided_rules_file_untouched`.
  No production logic was changed to make any test pass: every deletion in
  those commits is an expanded `use` line inside a `#[cfg(test)]` module.
- **What is deliberately NOT covered, and why.** `src/main.rs` holds 395 of
  the 661 originally-uncovered lines — socket binding, TLS listener setup
  and `process::exit` paths a hermetic unit test cannot reach. Excluding the
  binary entry point the library sits at ~94%. A further ~93 lines are
  conceded as unreachable: real network I/O with no injection seam (~45),
  process-global first-wins `OnceLock`/env state (~17), induced storage and
  syscall failures such as a poisoned SQLite handle (11), `spawn_blocking`
  `JoinError` interiors (~9), and provably dead defensive arms (6).
  `yara_fetch.rs`'s `MAX_TOTAL_BYTES` refusal is reachable — a working
  deflate bomb was built and verified — but the test cost 39 s and ~350 MB
  RSS in a debug build, so it was left out as a deliberate trade.
- **`cargo_tarpaulin` no longer hangs.** A run was killed after ~8 hours at
  100% CPU on `proptest_invariants`: tarpaulin's instrumentation is 10-50x
  slower than native, so 90 properties x 256 default cases never terminated.
  The coverage pass now caps `PROPTEST_CASES`/`QUICKCHECK_TESTS` at 2 —
  line coverage is identical at 2 cases or 256, and the full 256-case run
  still happens in the normal `test` and proptest gates.

### Fixed

- **`miri` could never run.** `test_miri.sh` defaulted `MIRI_PACKAGE` to
  `vl-core`, the sibling vulnerability-lookup-rs crate, so
  `cargo +nightly miri test --package vl-core` aborted rc=101. It now derives
  the package from `Cargo.toml`. Verified interpreting the real crate with
  zero soundness errors.
- **`cargo_kani` reported "0 harnesses".** The count grepped a `crates/`
  workspace path that does not exist here; it now searches `kani-harness/`
  and finds all 3. Note kani itself remains BLOCKED for an environmental
  reason: kani 0.67.0's pinned nightly rejects the unstable `cfg_select`
  feature used by `libsqlite3-sys 0.38.1`'s build script. That dependency
  predates this work and kani verified 3/3 proofs cleanly on 2026-07-27, so
  the toolchain moved, not the code.

## [1.6.51] - 2026-07-30

### Security

- **mimalloc secure allocator as `#[global_allocator]`.** Per
  `skills/rust-hardening`, the process now allocates through mimalloc in
  `secure` mode: guard pages, randomised placement, encrypted free lists and
  double-free detection. Safe Rust already prevents these classes in our own
  code (`forbid(unsafe_code)`), but a global allocator also governs the C
  allocations inside the FFI dependencies — `aws-lc-sys`, `ring`,
  `libsqlite3-sys` — which is precisely the code that attribute cannot reach.
  Those crates already build for all six release targets, so mimalloc's C
  introduces no new toolchain requirement. Measured cost: release binary
  41 536 156 -> 41 723 628 bytes (+187 KB, +0.45 %).
- **`overflow-checks = true` in `[profile.release]`.** Cargo disables integer
  overflow checks in release by default, so an overflow would WRAP silently —
  a wrapped length or offset is a classic logic and memory-safety bug. The
  crate already denies `clippy::arithmetic_side_effects`, so every arithmetic
  site is explicitly checked or saturating and a trap should be unreachable;
  this enforces at runtime what was previously only assumed. NOTE: combined
  with `panic = "abort"`, a genuine overflow terminates the process rather
  than unwinding — the intended trade-off, stopping on a detected integrity
  violation instead of continuing with a corrupted value.

### Changed

- **BSI Grundschutz++ catalog refreshed to 2026-07-29** (from 2026-07-03):
  190 controls modified, 28 added, 26 removed; 998 -> 1000 controls. The
  changes overlay is regenerated and both `.license` sidecars record the new
  retrieval. `scripts/update_catalog.sh` needed a URL fix first: BSI
  restructured their repository, moving the catalog out of
  `Anwenderkataloge/` into `control_layer/` and renaming it
  `Grundschutz++-resolved_catalog.json`, so the old URL 404'd. The new file
  was verified to be the same catalog before switching — identical title and
  top-level OSCAL keys, newer metadata version.
- **Dependency currency.** `rustls` 0.23.42 -> 0.23.43 (TLS 1.3 stack for both
  the HTTPS listener and the Meilisearch client), `http` 1.4.2 -> 1.5.0,
  `lo_writer` 0.4.8 -> 0.5.2, `lo_zip` 0.4.8 -> 0.5.2 (both pull `lo_core`
  0.5.2) and `infer` 0.19 -> 0.22. The `lo_*` and `infer` bumps cross a major
  version but needed no source change: the APIs this crate uses
  (`WriterEditor` / `save_as`, `ZipArchive::new` / `ZipEntry::new` /
  `write_zip_to_vec`, `infer::get`) are unchanged. No duplicate versions enter
  the graph, so `clippy::multiple_crate_versions` stays satisfied.
- **`base64` held at 0.22 deliberately.** 0.23.0 is available, but `pem` (via
  `rcgen`) and `ureq`/`ureq-proto` (via `self_update`) still require 0.22, so
  upgrading ours leaves BOTH versions in the graph permanently and fails the
  `clippy -D warnings` gate on `multiple_crate_versions`. Blocked upstream,
  not by this crate; revisit when those dependencies adopt 0.23.

### Added

- **CSAF advisories for the release and each dependency bump.**
  `ndaal-sa-2026-366` records the 1.6.50 release itself; `367` (rustls), `368`
  (http), `369` (lo_writer 0.4.10), `370` (lo_writer 0.5.2), `371` (lo_zip) and
  `372` (infer) record the dependency bumps. `370` supersedes `369`: that
  advisory recorded the intermediate 0.4.10 step before the dependency was
  taken to 0.5.2, and is left as published rather than rewritten. All are scored
  CVSS v3.1 0.0 / v4.0 0.0 (NONE) — verified against the RUSTSEC database
  rather than assumed: rustls RUSTSEC-2024-0336 (patched >= 0.23.5) and
  RUSTSEC-2024-0399 (patched >= 0.23.18) both predate 0.23.42, http
  RUSTSEC-2019-0033/0034 are patched >= 0.1.20 and apply to the 0.1.x line
  only, and `lo_writer` has never carried an advisory. They are therefore
  dependency-CURRENCY records, not security fixes. Each ships five
  tri-tool-verified hash sidecars; the distribution index is regenerated.

### Fixed

- **Quality-gate suite repaired — it could not run.** Landed during the 1.6.50
  cycle but recorded here, as 1.6.50 was already published. The tree did not
  compile (a deleted `[dev-dependencies]` section, a missing
  `chacha20poly1305` `rand_core` feature, a downgraded `tracing-subscriber` in
  the lockfile, and a `String` passed where `tracing` needs `&str`); once it
  did, ten gate runners were broken or unusable. The dominant cause was
  runners handing a whole-tree path to a file-walking tool: this worktree
  carries a >150 GB `target/`, so `rust_guardian` spun 1 h 38 min at 99 % CPU
  without emitting a finding, `codesearch` 68 min, `mkdlint` 11 min, and
  `typos` produced ~52 000 hits from build output. Each now analyses
  first-party source (`crates/` then `src/`) and completes in seconds.
- **Gate runners aimed at the wrong project or the wrong flag.**
  `cargo_cyclonedx` assumed a `crates/` workspace and a stale SBOM filename;
  `cargo_auditable` and `foxguard` built the sibling project's packages;
  `csaf_ndaal` resolved `${PROJECT_ROOT}/../csaf`, a sibling-repo layout, so
  all 354 advisories reported "missing"; and `pmat` had three defects that
  left it unable to report at all — it read `--mode`'s possible values instead
  of `--format`'s, passed the scan path positionally to a subcommand that
  accepts none, and parsed the grade "D" out of the heading "Grade
  Distribution". `bandit`, `gitleaks` and `betterleaks` walked the same
  150 GB tree, `gitleaks` eight times over (once per output format per pass);
  all three now scan only first-party source.
- **Five edition-pinned assertions repaired after the catalog refresh.**
  Three in `src/changes.rs` (the version window, the 100/81 -> 190/28 change
  counts, and the `GC.1.2` "added" exemplar plus `GC.2.2`'s previous-statement
  excerpt), one in `tests/features/change_tracking.feature` and one in
  `tests/test_bdd_inner.rs`. The latter two both used `BER.3.1.1`, which was
  *added* in 2026-07-03 but *removed* in 2026-07-29, so it no longer carries
  a change record at all; both now use `BER.5.1.1`. These assertions are
  inherently coupled to the catalog edition — every future refresh needs the
  same maintenance. All five were surfaced by `cargo mutants` aborting with
  "cargo test failed in an unmutated tree", the mutation baseline acting as a
  canary for edition drift.
- **The `goose` load-test gate had never actually run.** It probed `vl-web`,
  the sibling product, at `https://127.0.0.1:8080/api/v1/system/health`, so
  it self-skipped on every sweep. Repointed to this project's listener (8228,
  from `Cargo.toml`'s `default-port`) and to `/healthz`, which this app
  serves. With it finally running it failed for real — 960 of 16 160 requests
  erroring (5.94% against a 1% threshold), all `GET /control/{id}` — because
  `loadtest/data/control_ids.txt` was a stale snapshot: 17 of 40 sampled ids
  404'd after the refresh. Regenerated from the catalog the app serves
  (983 -> 1000 ids, 30/30 sampled verified); the gate now passes.
- **The coverage gate silently rewrote `Cargo.toml`.**
  `test_bash_with_kcov.sh` measures coverage by EXECUTING every runner,
  documented as using "each script's side-effect-free `--help` code path".
  `test_cargo_fmt_toml.sh` had no argument parsing at all, so `--help` was
  ignored and it ran its full body — including a `cargo fmt-toml` apply pass
  that rewrote the manifest, stripping ~123 lines of maintainer rationale and
  leaving `Cargo.toml_<TIMESTAMP>.backup` files behind. That script is not
  even in `ALL_GATES`; it only ever ran as collateral. `--help` now exits
  before anything is touched and the mutating pass is opt-in (`--apply` /
  `CARGO_FMT_TOML_APPLY=1`), while the coverage gate only measures runners
  that actually advertise `--help`. The assumption was broadly false: 111 of
  237 runners have none, so all of them were being executed for real —
  including runners that build, publish, scan and restart servers.
- **Self-skipping gates no longer report as PASS.** `run_cmd` recorded any
  runner exiting 0 as a pass, but every runner self-skips with exit 0 when its
  tool is absent or there is nothing to check. The suite header promises "a
  missing tool or runner is a SKIP (exit-neutral)"; the summary did not honour
  it, so the pass count overstated how much had actually been verified. The
  summary now surfaces the runner's own verdict — measured across six gates,
  `h3spec`, `quic_interop` and `rust_cli_with_trycmd` flip PASS -> SKIP while
  `fmt` and `typos` stay PASS.

## [1.6.50] - 2026-07-29

### Tests

- **Fourth fuzzing engine: cargo-afl (AFL++).** Per the updated
  `skills/rust-fuzzing` four-engine policy, added a standalone `afl/` crate (its
  own `[workspace]` + lockfile, isolated like `fuzz/` / `hfuzz/` /
  `loom-harness/`) with one AFL++ target per surface — 53 `afl_<mod>.rs`
  wrappers, each a thin
  `fn main() { afl::fuzz!(|d| grundschutz_fuzz_harness::<mod>::fuzz_drive(d)); }`
  over the SAME single-source `fuzz_drive` body already shared by the libFuzzer
  (`fuzz/`), honggfuzz (`hfuzz/`), and test-fuzz (`tf_*`) engines. AFL++ adds a
  genetic-algorithm + edge-coverage mutator distinct from libFuzzer's and
  honggfuzz's, so it reaches inputs the other three miss (OSS-Fuzz-style ensemble
  fuzzing); a parser change still lands once in `fuzz_drive` and all four engines
  pick it up. New nightly `cargo_afl` quality gate (build-check via
  `cargo afl build`; self-skips until `cargo-afl` + its one-time
  `cargo afl config --build` runtime are present). `afl/target/` is gitignored.
- **bolero unified front-end (+ Kani).** Added the go-forward pilot from
  `skills/rust-fuzzing` §5 at `tests/bolero_fuzz.rs`: three `bolero::check!()`
  harnesses over the crate's own public untrusted-input surfaces
  (`changes::CatalogChanges::from_json`, `i18n::resolve`,
  `updater::parse_sha256sums`). A plain `cargo test` replays the committed corpus
  as an ordinary test (3/3 green); `cargo bolero test <name> --engine
  afl|honggfuzz|kani` drives the SAME body under any coverage-guided engine or
  model-checks it with Kani. Additive to — not a replacement for — the four
  per-engine wrappers. `bolero` is a new dev-dependency (pulls
  bolero-engine / -generator / -libfuzzer / -kani); the nightly supply-chain
  gates (`cargo-vet` / `cargo-deny`) will need those crates covered.
- **Compiler-sanitizer gate (ASan/TSan/LSan/MSan).** New
  `tests/scripts/test_sanitizers.sh` builds and runs the library test suite
  under each of the four `-Z sanitizer` nightly instrumentations in turn
  (self-skips per-sanitizer when the target/toolchain combination doesn't
  support it). Scoped to `--lib` — the `bdd` cucumber integration binary
  fails to link under ASan and is unrelated to the sanitizer surface, so it's
  excluded rather than silently broken. Fixed a real `IFS=\n\t'`
  word-splitting bug in the script's own sanitizer-list parsing (the
  canonical boilerplate's IFS has no space, so a bare
  `for kind in ${SANITIZERS}` never actually split) — three call sites
  rewritten as `IFS=' \t\n' read -r -a arr <<< "${VAR}"`. A real run is
  clean: 36/36 tests pass under every sanitizer. Wired into
  `scripts/quality_gates.sh` as the `sanitizers` gate (Heavy/nightly).
- **Unblocked the full crate-wide `cargo-mutants` baseline.** The mutation
  gate's baseline build was hanging indefinitely inside cargo-mutants'
  scratch-tree copy — proptest's `FileFailurePersistence::SourceParallel`
  walk-up (looking for `lib.rs`/`main.rs`) never resolves correctly there.
  Pinned it instead to `FileFailurePersistence::Direct(...)` with a
  `CARGO_MANIFEST_DIR`-derived absolute path in
  `tests/proptest_invariants.rs`, verified against a real scoped
  `cargo mutants --file src/i18n.rs` run completing its baseline.
- **Closed all 34 remaining gaps in proptest/fuzz coverage** across the
  crate's untrusted-input-processing surface, following the audit protocol
  from `skills/rust-fuzzing`/`skills/property-based-testing`: 9 new
  `proptest!` cases in `tests/proptest_invariants.rs` (export format
  parsing, catalog JSON loading, CA-cert loading, archive install, annotation
  search, SQL-dump restore round-trip, export artifact/download building,
  PDF rendering) plus 23 new fuzz-harness modules under `fuzz-harness/src/`
  — each mirrored across all three engines (`fuzz/`, `hfuzz/`, `afl/`) via a
  new `scratchpad/generate_harness.py` generator. New coverage includes the
  three `/annotations/*` POST-handler seams (save/preview/upload), the
  export/DB-dump/dump-delete handlers, dump-file listing, image-asset
  storage, bulk template replacement, and the operator `--mappings-dir`
  loader. Two real bugs surfaced and were fixed while writing the tests, not
  just gap-filled: `export_pdf::Block` was missing `#[derive(Debug)]`
  (proptest requires `Debug` on generated parameters — a genuine compile
  error), and `routes::annotation::same_origin` was needlessly
  `pub(crate)`, blocking the CSRF-critical function from being fuzzed
  externally (widened to `#[must_use] pub`).
- **Synced the remaining ~61 `tests/scripts/` gate runners from
  vulnerability-lookup-rs**, completing the "full adapt" sync (each script
  rewritten to this app's single-binary, single-port-8228, no-HTTP/3 shape
  rather than copied verbatim). Wave 1 (33 scripts: `arch_graphs`,
  `cargo_acl`, `cargo_deadlinks`, `cargo_goggles`, `cargo_hack`,
  `cargo_minimal_versions`, `cargo_unmaintained`, `cargo_valgrind`,
  `charybdefs`, `creusot`, `css_with_stylelint`, `deductive_verification`,
  `flux`, `hotspots_cli`, `html_with_vnu`, `mirai`, `prusti`, `pumba`,
  `python_with_codeql`, `python_with_pylyzer`, `radamsa`, `ramparts`,
  `ripr`, `rust_with_codeql`, `rust_with_hela`, `sql_with_sqlness_cli`,
  `sqlite_integrity`, `sqllogictest_bin`, `stateright`, `taudit`,
  `turmoil`, `valgrind_extra`, `verus`) and wave 2 (28 scripts: `blint`,
  `bruno_dashboard_coverage`, `cats`, `dashboard_field_distinctness`,
  `dm_crash_consistency`, `h3spec`, `http_garden`, `interactsh`, `jonesy`,
  `katana`, `libfiu`, `oasdiff`, `promtool`, `protocol_abuse`,
  `quic_interop`, `reproducible_build`, `restler`, `rust_cli_with_assert_cmd`,
  `rust_cli_with_snapbox`, `rust_cli_with_trycmd`, `rustwright`, `soak`,
  `spectral`, `sql_with_sqlfluff`, `stress_ng`, `tlsfuzzer`, `toxiproxy`,
  `vacuum`) bring `scripts/quality_gates.sh` from 133 to 194 registered
  gates. Five candidates were confirmed as pre-existing duplicates and
  skipped rather than re-added (`betterleaks`/`gitleaks`/`leaktor`/`pyscan`
  gates already exist under those names; `hateoas_compliance_vulnlookup`
  duplicates the already-active `hateoas_compliance_grundschutz`).
  `h3spec`/`quic_interop` self-skip honestly — this app has no HTTP/3/QUIC
  listener (see the "Port 8181 / HTTP/3" note). The 3 `rust_cli_with_*`
  golden-file gates self-skip until `trycmd`/`snapbox`/`assert_cmd` become
  dev-dependencies. 13 of the wave-2 LIVE gates were retrofitted with an
  opt-in `*_STRICT` env var (default report-only) to match this repo's
  established `apihunter`/`pumba`-style convention for live DAST/chaos
  gates, rather than always exiting 0.

## [1.6.49] - 2026-07-22

### Fixed

- **Accessibility: keyboard access for scrollable regions (WCAG 2.1 AA).**
  axe-core flagged three overflow scroll containers as keyboard-inaccessible
  (`scrollable-region-focusable`, impact serious — a WebKit/Safari-specific
  rule): the annotation-source and raw-OSCAL-JSON `<pre>` blocks on the
  control-detail page (Bootstrap reboot makes `<pre>` `overflow:auto`) and the
  `.settings-dropdown` framework grid (`overflow-y:auto`). Added `tabindex="0"`
  to each so keyboard users can focus and scroll them. The Playwright a11y
  suite is green across chromium/firefox/webkit (18/18).

### Tests

- **Double-Loop TDD: cucumber BDD acceptance layer.** Added an outer
  behaviour-driven acceptance loop (per
  `skills/rust-behaviour-driven-development`) wrapping the existing
  `#[test]` / proptest inner loop. A `harness = false` `bdd` test target
  (`tests/bdd.rs` runner + `tests/bdd/steps.rs` step glue) drives four Gherkin
  feature files under `tests/features/`, each written in stakeholder language
  and traced to a requirement id: REQ-CAT-001 (catalog browsing + control
  lookup), REQ-CHG-001 (catalog-change Updated / New classification),
  REQ-SEC-001 (safe annotation Markdown rendering), REQ-SEC-002 (Host-header
  allowlist / anti-DNS-rebinding). `@wip` + `WIP=1` code-level gating keeps
  in-progress scenarios out of the committed suite while surfacing them on
  demand; the default run uses `filter_run_and_exit` + `fail_on_skipped` so a
  red or undefined step fails CI. A parametrised `rstest` inner-loop demo
  (`tests/test_bdd_inner.rs`) exercises the change-kind and Host-allowlist units
  the scenarios sit on. New dev-dependencies `cucumber` 0.23 + `rstest` 0.26 and
  a new native `bdd` quality gate (also covered by the `test` / `cargo_nextest`
  gates).
- **Three-engine fuzzing (libFuzzer + honggfuzz + test-fuzz).** Adopted the
  `skills/rust-fuzzing` multi-engine policy: every one of the 53 untrusted-input
  surfaces now has a single-source `fuzz_drive(data: &[u8])` body in the new
  standalone `fuzz-harness/` crate (own workspace, like `loom-harness/`), driven
  by all three engines — the existing `fuzz/` libFuzzer targets refactored to
  thin wrappers (primary, PR gate), a new `hfuzz/` crate with 53 honggfuzz
  targets (`hongg`, second coverage engine), and a `tf_<mod>(Vec<u8>)` test-fuzz
  wrapper per module (`#[cfg_attr(test, test_fuzz::test_fuzz)]`, dev-only). A
  parser change now lands once for all three engines. All 53 targets build under
  each engine (verified on x86_64-apple-darwin). New nightly `honggfuzz` +
  `test_fuzz` quality gates (build-checks; the coverage runs are
  `cargo hongg run` / `cargo test-fuzz`).
- **Proptest: open-redirect invariant for the language switcher.** Added a
  property test pinning `routes::lang::return_path` — the post-language-switch
  redirect target it derives from the client `Referer` is always a server-local
  path (starts with `/`, never `//`), so a crafted `Referer` can never bounce a
  user to an attacker origin. A biased `prop_oneof!` strategy feeds it the
  absolute / protocol-relative URL shapes that would slip through if the `//`
  guard ever regressed; the `fuzz_lang_return_path` target only proved
  panic-freedom. Cross-checking all 53 fuzz surfaces against the proptest suite,
  this was the only pure function whose security invariant was fuzzed but not
  yet asserted as a property — the other 52 were already covered.
- **Proptest: four more pure-function invariants from a whole-tree audit.** A
  multi-agent audit of all 45 `src/` modules (widening the net beyond the 53
  fuzzed surfaces) cross-referenced every pure function against the proptest +
  fuzz inventory and adversarially verified each candidate. It surfaced four
  uncovered pure functions with falsifiable security / correctness contracts,
  now pinned as properties (and exposed `pub` + `#[must_use]` per the repo
  convention for tested pure fns):
  - `meili::is_loopback_host` — the classifier that permits a plaintext
    `http://` Meilisearch base URL (the Bearer API key is later sent over that
    connection), so a false positive is a cleartext-credential-exposure
    regression. The property enumerates loopback forms (`localhost`, brackets,
    trailing dot, `127.0.0.0/8`, `::1`) as true and non-loopback authorities as
    false.
  - `routes::annotation::select_version` — the NO-FALLBACK revision selector
    (first, else exact match, else `None`), distinct from its max-fallback twin
    `controls::pick_annotation_revision`.
  - `routes::export::human_bytes` — binary-unit size formatter; pins the
    `>= 1024` unit boundary and the floored-magnitude arithmetic.
  - `yara_fetch::parse_sha256_digest` — the untrusted `sha256:<hex>` digest
    parser; pins the 64-lowercase-hex shape and the whitespace-tolerant
    round-trip.
  The audit also confirmed **zero** remaining fuzz-target gaps: every
  untrusted-input surface already has a libFuzzer / honggfuzz / test-fuzz target.

## [1.6.48] - 2026-07-22

### Security

- **Annotation SVG-upload false-reject fixed** (fail-closed; no security
  impact — `ndaal-sa-2026-353`, CVSS 0.0/NONE). A valid, safe SVG whose bytes
  begin with an XML prolog (`<?xml …?>`, what most editors emit) was rejected
  by `validate_upload` as a content/type mismatch: `infer` has no
  `image/svg+xml` matcher and sniffs a prolog-bearing SVG as `text/xml`, which
  the declared-SVG branch treated as a mismatch *before* the authoritative
  `validate_svg` allowlist ran. The sniff guard now treats any XML-family sniff
  as consistent with SVG and lets `validate_svg` (unchanged) decide; a
  positively non-XML sniff (a real PNG declared as SVG) is still rejected. A
  regression test (`svg_with_xml_prolog_is_accepted`) plus a PNG-as-SVG
  rejection are added.

### Changed

- Dependency currency (`cargo update`, all semver-compatible, no source change;
  one CSAF advisory per bump, all CVSS 0.0/NONE): `http-body-util` 0.1.3→0.1.4
  (`ndaal-sa-2026-346`), `hyper` 1.10.1→1.11.0 (347), `serde` 1.0.228→1.0.229
  (348), `serde_json` 1.0.150→1.0.151 (349), `time` 0.3.53→0.3.54 (350),
  `tokio` 1.52.3→1.53.1 (351), `self_update` 1.0.0-rc.5→1.0.0-rc.6 (352). The
  `serde_derive` 1.0.229 bump pulls `syn` 3.x transitively; it is added to
  `clippy.toml`'s documented `allowed-duplicate-crates`.

### Tests

- Compile-time type invariants via `static_assertions` (dev-dependency).
  `tests/static_assertions_invariants.rs` exercises every macro family
  (`assert_impl_all` / `assert_impl_any` / `assert_impl_one` /
  `assert_not_impl_all` / `assert_not_impl_any` / `assert_obj_safe` /
  `assert_eq_size` / `assert_eq_align` / `assert_fields` /
  `assert_type_eq_all` / `assert_type_ne_all` / `const_assert` /
  `const_assert_eq` / `const_assert_ne`) against real invariants: the catalog
  and change-overlay types are `Send + Sync` and never `Copy`, the tag enums
  are single-byte (with a niche-optimised `Option<ChangeKind>`), the router
  type aliases and struct field shapes are pinned, and the search/import limit
  constants keep their required ordering. Zero runtime cost — a regression in
  any of these becomes a compile error.
- A reusable-test audit of the upstream dependency test suites added three
  known-answer / policy tests our coverage lacked: a **BLAKE3-512 known-answer
  vector** in `src/sidecar.rs` (the last of the five hash families to gain a
  fixed KAT), a **markdown XSS-protocol battery** in `src/annotation.rs`
  (`javascript:` / `vbscript:` / `data:` across links and images), and a **TLS
  certificate parse-back** in `src/tls.rs` asserting the self-signed cert is
  ECDSA (the ndaal no-RSA policy) with the three loopback SANs (adds
  `x509-parser` as a dev-dependency).

## [1.6.47] - 2026-07-15

### Changed

- Embedded BSI Grundschutz++ catalog refreshed from the official
  Stand-der-Technik-Bibliothek: `2026-05-28` → `2026-07-03` (998 controls, 20
  practices; 100 controls substantively changed, 81 added, 81 removed).
  `scripts/update_catalog.sh` now also regenerates the change overlay and its
  CC-BY-SA-4.0 sidecar on every refresh. Note: the hand-maintained framework
  crosswalks in `data/mappings/*.csv` still key ~81 removed control ids; those
  rows simply never display and a full crosswalk re-audit is tracked
  separately.
- Server-stack test coverage (completes the E harness from 1.6.46): a
  heavy-op-503 shedding test — 3 concurrent `POST /export/db-dump` over TLS
  yield exactly 2×`200` + 1×`503`, then a serial op succeeds, proving the
  audited single-flight permit is released (no leak) — and an HTTP/2 budget
  test asserting the wire-advertised `SETTINGS_MAX_CONCURRENT_STREAMS = 128`.
  Adds `h2` as a dev-dependency (already present in the graph via hyper).

### Added

- **Catalog-change tracking.** The embedded catalog is diffed against the
  previous edition (`scripts/diff_catalog.py` → embedded
  `data/grundschutz-plus-plus-changes.json`). A control whose *substantive*
  content changed — title, statement, guidance, MUSS/SOLLTE/KANN, security and
  effort level, tags or parameters (internal `class`/UUID/namespace churn is
  deliberately ignored) — now shows an **Updated** badge on its detail page and
  a *Previous version* card rendering the old content beside the new for
  comparison; a brand-new control shows a **New** badge (`src/changes.rs`,
  trilingual DE/FR/EN).
- Automated accessibility gate (`@axe-core/playwright`, WCAG 2.1 AA / BITV 2.0
  / EN 301 549) over home / controls / control-detail / export / annotations /
  settings, plus a Playwright visual-regression gate with committed baselines
  (home, control detail, export form, settings grid × chromium/firefox/webkit).
  The `color-contrast` rule is scoped out with a documented exemption — the
  Bootstrap theme's AA-contrast rework is tracked as a follow-up; every other
  WCAG 2.1 AA rule (ARIA, labels, landmarks, roles, names) is enforced.

### Tests

- Property + fuzz coverage for the catalog change-overlay parser
  (`src/changes.rs`) — the last embedded-data deserialiser that lacked either.
  A proptest round-trips a generated overlay (every recognised
  `added`/`modified` kind and the presence/absence of its `previous` content
  survive parsing exactly — the data that drives the Updated/New badge and the
  previous-version card — while an unrecognised `kind` is dropped, never
  stored) plus a never-panics proptest over arbitrary input, mirrored by a new
  `fuzz_changes_parse` cargo-fuzz target with a seeded corpus.
- End-to-end coverage for the catalog change-tracking UI (the old/new content):
  three Bruno requests (`core/14-16`) and a Playwright spec
  (`catalog-changes.spec.ts`) assert the Updated badge + previous-version card
  (with the old title rendered beside the new) on a modified control
  (`BER.3.1`), the New badge with no previous-version card on an added control
  (`BER.3.1.1`), and neither marker on an unchanged control (`GC.1.1`).
  Assertions are scoped by the badge/card icon classes, each unique to
  `control_detail.html`, so no sibling badge can match.

### Documentation

- Administrator guide: document `--allowed-host` / `GSV_ALLOWED_HOSTS` (the
  1.6.46 anti-DNS-rebinding Host allowlist) in the CLI-flag and env-var tables,
  plus a §3.2 reverse-proxy note — a proxy that forwards the public `Host`
  (`proxy_set_header Host $host;`) now needs `--allowed-host <hostname>`, or
  must send the loopback authority, else the viewer answers `403`.

## [1.6.46] - 2026-07-15

### Security

- **Anti-DNS-rebinding Host allowlist.** Every request's `Host` / `:authority`
  is now pinned to the bind authority, its loopback aliases
  (`127.0.0.1` / `localhost` / `[::1]` on the bound port), and any
  operator-configured `--allowed-host` / `GSV_ALLOWED_HOSTS`; a foreign `Host`
  is refused with `403` before dispatch. This closes the DNS-rebinding path
  that a same-origin CSRF gate alone cannot (`src/host_guard.rs`, wired into
  the `SharedRouter::handle` chokepoint).

### Added

- `--allowed-host` (env `GSV_ALLOWED_HOSTS`) — comma-separated extra `Host`
  authorities accepted by the anti-DNS-rebinding guard, for real-hostname or
  reverse-proxy deployments.
- `specs/openapi.yaml` — an OpenAPI 3.1 contract for all 38 routes, making the
  previously-inert `schemathesis` gate real (it had self-skipped for lack of a
  spec and targeted a non-existent port).

### Changed

- The three `/annotations/*` POST handlers now factor their post-body logic
  into testable sync seams (`run_annotations_{save,preview,upload}_request`),
  mirroring the export handlers — with `same_origin` and the (injectable)
  malware-scan verdict inside the seam so the CSRF (403), validation
  (400/404/413/429) and malware-rejection (422) branches are all unit-tested.
- The `cargo-tarpaulin` gate is now blocking: it enforces a coverage floor
  (`--fail-under`, default 80%, env `TARPAULIN_FAIL_UNDER`) and propagates a
  below-floor / crash exit code instead of always exiting `0`.
- The `schemathesis` gate now targets the real listener (`127.0.0.1:8228`) and
  no longer probes a non-existent HTTP/3 port.
- The server accept loop is split into a spawnable `serve(...)` with a tunable
  `ServerLimits` (max-connections + the TLS-handshake / header-read / request
  timeouts), backing a new server-stack test harness that drives a real
  in-process TLS listener: the header-read (slowloris) timeout and the
  connection-admission shed/recover path — previously configured but never
  behaviorally tested — are now asserted end-to-end. Production defaults are
  unchanged.

## [1.6.45] - 2026-07-14

### Dependencies

- `sha3` 0.11 → 0.12 (`sha3 = "0.12"`), plus a new direct dependency
  `shake` 0.1 (`shake = "0.1"`). This completes the XOF-crate migration the
  1.6.44 entry deferred: sha3 0.12 **modularised** the SHAKE /
  extendable-output-function types out into the dedicated `shake` crate — they
  were **moved, not removed** — so `src/sidecar.rs` now sources `Sha3_512`
  from `sha3` and `Shake256` + the XOF traits from `shake`, while the mandated
  five-hash-family sidecar contract (`.sha-256` / `.sha-512` / `.sha3-512` /
  `.blake3-512` / `.shake256-512`) is preserved unchanged.
- The migration is **byte-for-byte behaviour-preserving**: the FIPS-202
  known-answer tests (`sha3_512_known_answer`, `shake256_512_known_answer`)
  produce identical digests before and after, and `sha2` / `sha3` / `shake`
  share the same `digest` cohort so `Sha3_512::digest` still resolves against
  the `sha2::Digest` import. No RUSTSEC advisory on either crate.
- Each change is recorded as a dependency-currency CSAF 2.1 advisory scored
  CVSS v3.1 0.0 / v4.0 0.0 (NONE), with all five hash-family sidecars:
  - `csaf/2026/285/` — sha3 0.11 → 0.12 currency (with the shake source
    change).
  - `csaf/2026/286/` — new `shake` 0.1 dependency (SHAKE256 XOF, moved out of
    sha3 0.12).

### Added

- Exhaustive checksum-sidecar test sequence (documented as §11 in
  `documentation/test_sequences.md`): a deterministic
  `write_sidecars_exhaustive_over_all_32_flag_combinations` test that walks
  **every one of the 2⁵ = 32** hash-family enable/disable combinations and
  asserts the writer emits exactly the enabled sidecar set — the total
  counterpart to the sampling `write_sidecars_binds_*` proptest — plus a
  `shake256_512_hex_is_deterministic_64_bytes_lowercase_hex` property that
  guards the XOF's width and determinism across the whole input space (the
  `fuzz_sidecar` target already drives `shake256_512_hex`, so the moved code
  path is fuzz-covered without a new target).
- Importable annotation template derived from CISA Binding Operational
  Directive BOD 26-04 (*Prioritizing Security Updates Based on Risk*), in all
  three viewer languages —
  `templates/CISA_BOD_26-04_Remediation_Timelines_template_{EN,DE,FR}.md`:
  the four decision points, the full 16-branch Table 1 remediation timelines,
  and the CISA decision-tree graphic reproduced as a colour Mermaid flowchart
  (outcomes coloured by urgency, labels localised per language). Ships
  pre-rendered colour companions `CISA_BOD_26-04_Remediation_Timelines.svg` /
  `.png` (EN) plus `_DE` / `_FR` variants.
- `--seed-demo` patch-management remediation extension: the ≤ 15 patch/update
  controls selected by Grundschutz++ catalog title (`DET.5.10`, `KONF.8.1.1`,
  `DEV.5.1`, … 11 in total) receive nine extra annotation versions (V16–V24)
  after the base fifteen-version history. For each of French, English, German
  in turn: a version removing the whole content (draft), the CISA BOD 26-04
  remediation Markdown (draft), and a released version with the rendered colour
  graphic embedded where the diagram belongs (FR svg, EN png, DE svg) — so
  those controls carry 24 versions and 11 assets. Covered by new unit tests
  plus an integration sequence in `tests/test_seed_demo.rs`.

### Changed

- Opened the 1.6.45 development line (version bump from 1.6.44, which was
  released to crates.io and GitLab).

## [1.6.44] - 2026-07-14

### Dependencies

- Currency bumps of two direct dependencies, both semver-/pre-release-
  compatible with no first-party source change (`cargo metadata` resolves
  unchanged):
  - `rustls` 0.23.41 → 0.23.42 (via `cargo update -p rustls`, honouring the
    existing `rustls = "0.23"` requirement) — the TLS 1.3 server + self-update
    client transport. No RUSTSEC advisory on either version.
  - `self_update` 1.0.0-rc.3 → 1.0.0-rc.5 (Cargo.toml requirement advanced to
    `"1.0.0-rc.5"` — a pre-release requirement must name the exact `rc` floor,
    since `cargo update` alone will not cross `rc` boundaries) — the opt-in
    self-updater behind `--check-update` / `--self-update`.
  - `sha3` was intentionally **left at 0.11** (not bumped to 0.12): 0.12
    dropped the SHAKE / XOF API that `src/sidecar.rs` needs for the mandated
    `SHAKE256-512` hash-family sidecar. Bumping it would break the repo's
    five-hash-family integrity contract, so it stays pinned pending a real
    XOF-crate migration.
- Each bump is recorded as a dependency-currency CSAF 2.1 advisory scored
  CVSS v3.1 / v4.0 **0.0 (NONE)** with all five hash sidecars:
  `csaf/2026/282` (rustls) and `csaf/2026/283` (self_update). Both pass
  `csaf-validator -T basic -C 2.1`.
- Committed the dependency/security advisory batch `ndaal-sa-2026-254 … -281`
  (28 advisories, each with the full five-hash-family sidecar set — all valid
  JSON, 5/5 sidecars) and refreshed `csaf/index.txt` + `csaf/changes.csv`.

### Tests

- Closed six genuinely-uncovered property/fuzz gaps found by a survey +
  adversarial-verify audit of every `src/` untrusted-input / invariant-
  bearing function against the existing 51 fuzz targets + 64 proptest
  properties:
  - `oscal::Control::tags` — the `?tag=` filter + search-haystack feeder
    is trimmed, non-empty, comma-free, and equals its reference transform.
  - `sidecar::write_sidecars` — the extension↔digest WIRING (all 2^5 flag
    combos): each `{rel}.{ext}` exists iff enabled and its body is exactly
    the digest bound to that extension — a copy-paste swap the per-algorithm
    known-answer tests cannot catch.
  - `store::Store::sql_dump` — arbitrary bodies (quotes, backslashes, NUL,
    CR/LF, control chars, non-ASCII, injection fragments) round-trip
    losslessly through `restore_sql_dump` → `open` → `latest`, guarding the
    hand-rolled `value_to_sql` literal renderer.
  - `routes::raw::download_filename` — proptest **and** a new
    `fuzz_download_filename` target: the `Content-Disposition` name is always
    a single, non-traversable, pure-ASCII `*.json` component with an
    `[a-z0-9._-]` stem (exposed `pub` for the harness; no behaviour change).
  - `routes::annotation::same_origin` — the `Sec-Fetch-Site` override is an
    exact `same-origin`/`none` match, and the Origin arm requires `https`
    plus a byte-equal authority.
  - `routes::stats::percent` — never panics, `0` on a zero total, and
    stays in `0..=100` whenever `count <= total` (bar width can't exceed
    100%; not asserted to reach 100 under `u64` saturation).
- Fixed three performance-analysis gate scripts that silently analysed
  **nothing** — `test_cargo_bloat.sh`, `test_cargo_llvm_lines.sh`, and
  `test_cargo_flamegraph.sh` targeted stale `vl-web` / `vl-cli` / `vl-core`
  names inherited from the reference project, so they exited `0` without
  ever touching this repo's only binary. Retargeted to
  `grundschutz-oscal-viewer`; also corrected cargo-bloat's invocation
  (`--crates -n 0` for the complete per-crate view; the default per-function
  view capped to the top `BLOAT_FN_LINES` (200) — `-n 0` there dumps ~1.3 MB
  of every symbol; `--functions` is not a real flag). A "green" gate that
  never exercises its surface is worse than none.
- Rewrote the HATEOAS gate for grundschutz and renamed it
  `tests/scripts/test_hateoas_compliance_grundschutz.{sh,bats,README.md}`. It
  was a verbatim `vulnerability-lookup-rs` artifact probing CVE / sighting /
  KEV / Debian JSON endpoints this app has never had (default `:8080` — the
  reference server). It now validates grundschutz's actual hypermedia +
  HATEOAS surface on `:8228`: health/readiness, the JSON API contract
  (`/catalog.json`, `/control/{id}/raw.json` echoing its id + a 404 for
  unknown ids), the in-band nav-link discoverability guarantee on `/`, live
  control→detail→raw.json navigation, bounded pagination, the htmx fragment
  swap, and 404 error handling. Self-skips cleanly when no server is
  listening. Fixed a `pipefail`+`grep -q`+large-body SIGPIPE bug in the
  process, and made it fully `shellcheck -o all`-clean — the bats gate for
  that (`test #4`) was red on the original. Promoted from the "inapplicable"
  exclusion list to an active LIVE gate (`hateoas_compliance_grundschutz`) in
  `scripts/quality_gates.sh` (self-skips when no server is listening).
- Added six `proptest` mirrors in `tests/proptest_invariants.rs` for
  untrusted-input parsers that previously ran only under a dedicated
  `cargo fuzz` session, so their invariants are now checked on every
  `cargo test` / CI build (the stated purpose of that file):
  - `router::decode_query_value` — exact inverse of
    `encode_query_component` (the `/controls` filter-pill codec).
  - `updater::resolve_redirect` — never follows an absolute `http://`
    redirect (the self-update scheme-downgrade refusal).
  - `routes::controls::safe_external_url` — only ever emits an `https`
    URL or the empty string (the mapping-crosswalk link gate).
  - `meili::parse_task_state` / `parse_task_uid` — total, `Pending`-
    and `None`-defaulting Meilisearch task-poll parsers.
- No production code changed. The fuzz side was already complete (51
  targets, all in sync with `fuzz/Cargo.toml`); this fills the matching
  CI-checked property coverage.
- Added a runnable **Goose load-test gate**: `tests/scripts/test_goose.sh`
  (+ sibling `test_goose.bats` / `test_goose.README.md`) builds and runs the
  standalone `loadtest/` goose crate against the live HTTPS listener with a
  small bounded load, parses goose's JSON metrics, and fails when the request
  error rate exceeds `GOOSE_MAX_ERROR_RATE` (default `0.01`). Self-skips
  cleanly without cargo / the crate / `jaq`, or when the target is
  unreachable. Wired into `scripts/quality_gates.sh` as the `goose` gate
  (Heavy/nightly LIVE group, skipped by `--fast`).
- Documented the **Loom** (§8) and **Goose** (§9) sequences in
  `documentation/Test_Sequences.md` — what each proves, how to run the
  bounded gate vs the full soak, and the `quality_gates.sh` wiring.
- Extended the **Loom harness** (`loom-harness/`) with two new models over
  real `src/` synchronization, verified across every interleaving under
  `RUSTFLAGS="--cfg loom"` (all 5 models pass):
  - **Model D** — `routes::export::heavy_ops()`, the `Semaphore::new(2)`
    single-flight for export / download / db-dump (audit 0.4.27 #2/#3): three
    callers racing for two permits never over-issue (≤ 2 heavy ops at once), a
    caller that fails `try_acquire` never runs (the 503 `busy_response` path),
    and no permit leaks. Models the semaphore *contract* via a `Mutex`-guarded
    permit pool (Loom can't drive tokio's `Semaphore`).
  - **Model E** — `Store::BulkWrite`: its terminal (COMMIT xor ROLLBACK) is
    applied through the shared `store.lock()` exactly once, and a concurrent
    reader never observes a half-applied transaction. Scope caveats (contract
    vs. tokio; single-caller `BulkWrite`) stated in `loom-harness/README.md`.
- Extended the **Goose load test** (`loadtest/`) with an opt-in `Mutate`
  scenario exercising the same-origin-guarded `POST` routes
  (`/annotations/preview`, `/annotations/save`). Gated behind
  `GSV_LOADTEST_INCLUDE_MUTATE=1` so the default read-only gate never mutates
  state; `save` targets a throwaway `--seed-demo` instance. Verified
  end-to-end against a live throwaway listener (160 requests, 0 failures).
- Added a runnable **Criterion micro-benchmark** crate (`criterion-benches/`,
  its own workspace like `loom-harness/` / `loadtest/`, so criterion's
  benchmark dependency tree never enters the viewer's published crate or its
  strict deny/geiger/machete gates). `benches/hot_paths.rs` path-depends on
  the real viewer lib and benches four hot paths as compiled for release:
  the embedded-catalog parse (`index::CatalogData::from_json`, ~998 controls),
  the `/controls` filter (`filter::apply`), the `/search` fallback
  (`search::search_builtin`), and the filter-pill codec
  (`router::decode_query_value`). The `test_cargo_criterion.sh` gate — which
  previously always self-skipped (it looked for `[[bench]]` under a
  nonexistent `crates/` dir) — now runs `cargo criterion` inside the crate,
  capturing every output format plus a jaq-derived SARIF 2.1.0 report. Wired
  into `scripts/quality_gates.sh` as the `cargo_criterion` gate (Heavy/nightly
  LIVE group, skipped by `--fast`).

### Documentation

- Documented the test-coverage layers (property-based invariants, `cargo fuzz`
  targets, and the API / HATEOAS contract gate) in a new **Test coverage**
  section of the trilingual README mirror — `README.md`, `LIESMICH.md` (DE),
  and `LISEZMOI.md` (FR).

## [1.6.43] - 2026-07-10

### Security

- Published a CSAF 2.1 advisory, `ndaal-sa-2026-252`, retroactively
  documenting the DB-dump availability finding fixed in 1.6.41: before that
  release, `Store` serialized every database operation behind one shared
  `Mutex<Connection>`, and the dump endpoint ran `VACUUM INTO` + a full SQL
  serialize while holding that mutex, so a single dump blocked every other
  request — including `/healthz` and `/readyz` — for the dump's whole
  duration (~61 minutes at the full-catalog `--seed-demo` scale). Scored
  **CVSS v3.1 6.2 / v4.0 6.9 (MEDIUM)** — availability-only, LOCAL vector
  (loopback-by-default binding + the same-origin gate on the dump endpoint),
  a temporary self-recovering total unavailability. Affected: versions with
  the DB-dump feature (0.3.26 through 1.6.40); fixed in 1.6.41 via
  `Store::dump_connection`'s independent connection.

### Tests

- **Loom permutation-testing harness** in a standalone `loom-harness/` crate —
  its own workspace, like `kani-harness/` and `loadtest/`. Loom's `--cfg loom`
  flag propagates to the whole dependency tree, and `tokio` gates its `net`
  module behind `#![cfg(not(loom))]`, so an in-crate loom test cannot compile
  (the viewer's own tokio/hyper tree breaks). The standalone crate has none of
  that tree; it **models** `src/store.rs`'s synchronization shape with Loom's
  own `Mutex`/`Arc`/`AtomicBool`/`thread`:
  - `shared_mutex_serializes_writes` — the shared `Mutex<Connection>`
    serializes concurrent writers with no lost update.
  - `dump_two_phase_never_deadlocks_and_no_torn_read` — a dumper
    (`dump_connection`'s path-copy), a writer, and a reader sharing the mutex
    are deadlock-free (including a self-deadlock from a reentrant re-lock) and
    never observe a torn read, across every interleaving.
  - `meili_ready_latch_settles_true` — a minimal smoke model of the
    `meili_ready` `AtomicBool` latch.

  Each model's teeth were verified empirically (mutating it to violate its
  invariant makes Loom fail: a lost-update race and a deadlock were both
  caught). The harness is explicit about scope: Loom has no notion of time, so
  it does **not** prove the dump releases the lock *early* / stays non-blocking
  — that real-time property remains covered by
  `store::tests::vacuum_into_does_not_block_concurrent_reads` and the 1.6.41
  live verification. Runner: `tests/scripts/test_loom.sh` (self-skips when
  cargo/the crate is absent) with its sibling `test_loom.bats` (39 assertions)
  and `test_loom.README.md`; wired into `scripts/quality_gates.sh` as the
  `loom` gate alongside `miri`.

## [1.6.42] - 2026-07-09

### Added

- **7 new framework toggles**, mirroring framework additions proposed
  upstream at [CISO Assistant Community](https://github.com/intuitem/ciso-assistant-community)
  (toggle metadata only — display name + default-disabled state; no mapping
  data yet): `itsap-10-035` (ITSAP.10.035, Canadian Centre for Cyber
  Security), `sama-itg-1-0` / `sama-bcm-1-0` (SAMA IT Governance / Business
  Continuity Management), `cscc-1-2019` / `dcc-1-2022` / `ecc-2-2024` (Saudi
  NCA Critical Systems / Data / Essential Cybersecurity Controls),
  `pipeda` (Canada's federal privacy law). `ecc-2-2024` is additive
  alongside the existing `essential-cybersecurity-controls-ecc` entry
  (older ECC-1 edition), matching this project's convention of keeping
  versioned frameworks as separate toggles.

### Security

- Published a CSAF 2.1 advisory recording the 7 new framework toggles —
  `ndaal-sa-2026-251` — scored **CVSS v3.1 0.0 / v4.0 0.0 (NONE)**. Adds
  disabled-by-default UI toggles with no bundled mapping data and no new
  parsing or network surface; documents product currency, not a fix.

### Documentation

- Corrected the `loadtest/README.md` "excluded by design" note, which
  grouped `/export/run`, `/export/download`, and `/export/dumps/download`
  under "every POST" — only `/export/run` actually has a POST handler
  (`export_submit`); the other two are GET-only and are excluded because
  they depend on files a prior POST generated, not because they're POSTs
  themselves. No behavior change.

## [1.6.41] - 2026-07-09

### Changed

- **Dependency-currency refresh — no first-party source changes.** Two
  semver-compatible dependency bumps picked up via `cargo update` (respecting
  the existing manifest requirements):
  - `bytes` 1.12.0 → 1.12.1 (direct; HTTP layer byte buffers)
  - `self_update` 1.0.0-rc.2 → 1.0.0-rc.3 (direct; the opt-in
    `--check-update` / `--self-update` feature)
  `sha3` was again deliberately **held** at 0.11 — 0.12 drops the SHAKE/XOF
  the checksum sidecars need.

### Fixed

- **Database dump (`VACUUM INTO`/SQL dump) blocked the entire application
  while it ran.** `Store::vacuum_into`/`Store::sql_dump` ran over the same
  shared `Mutex<Connection>` guarding every other `Store` method, so a
  single long-running dump held that mutex for its full duration — every
  other request (page renders, saves, even `/healthz`/`/readyz`) queued
  behind it. At the `--seed-demo` full-catalog scale (~1000 targets,
  ~1.2 GB) this measured **~61 minutes of total application
  unavailability** from one dump request — the `VACUUM INTO` itself ran
  61 minutes server-side while holding the shared mutex, and a concurrent
  `/readyz` health-check probe issued partway through that window waited
  26 minutes for its turn. Fixed by opening an independent
  connection to the same database file for the dump
  (`Store::dump_connection`) instead of reusing the shared connection —
  safe because the store already runs in SQLite WAL journal mode, which
  supports one writer plus concurrent readers without engine-level
  blocking; the mutex, not SQLite, was the actual bottleneck. Verified live
  against the real seeded database: `/readyz` stayed under 100 ms across
  200+ polls during a 120 s+ dump, and a new regression test
  (`vacuum_into_does_not_block_concurrent_reads` in `src/store.rs`) asserts
  a concurrent read never waits more than 500 ms behind a running dump.
  The general 120 s request timeout is unchanged — a dump against the full
  catalog can still answer the client with 408 while it keeps running
  safely in the background; `test/bruno/seed-demo/{06,10,11}-*.bru` now
  accept either outcome, and `12-export-lists-dumps.bru` no longer assumes
  request 11's write has landed by the time it runs.

### Security

- Published two CSAF 2.1 advisories recording the dependency-currency
  bumps — `ndaal-sa-2026-249` (bytes), `-250` (self_update) — each scored
  **CVSS v3.1 0.0 / v4.0 0.0 (NONE)**. Neither superseded version carried a
  RUSTSEC advisory (`cargo deny check` = advisories ok); these document
  supply-chain currency, not a fix.

### Tests

- **Closed a proptest/fuzz coverage gap audit.** Six functions that were
  fuzzed alongside an already-covered sibling, or trusted only by
  hand-written unit tests despite parsing untrusted input, are now `pub`
  (matching the project's existing fuzz-target convention — the enclosing
  modules were already public) and exercised by new dedicated fuzz targets:
  `yara_fetch::install_archive` (the hand-rolled ZIP central-directory
  parser behind the downloaded YARA-Forge rule archive — hand-hardened
  twice before but never coverage-guided fuzzed), `routes::controls::
  safe_external_url` (the untrusted `--mappings-dir` URL guard),
  `updater::resolve_redirect` (the self-update HTTP redirect resolver),
  `meili::parse_task_state`/`parse_task_uid` (Meilisearch task-response
  parsing), `routes::lang::return_path` (the `Referer`-derived
  post-language-switch redirect target), and `store::Store::
  search_annotations` (the `/search?q=` query against stored bodies). Adds
  `tests/proptest_invariants.rs` properties for seventeen further pure
  functions that had a fuzz target or solid unit tests but no mirrored
  property (e.g. `filter::apply`/`paginate` bounds, the percent-encode/
  decode round trip, the five checksum-sidecar digest functions, the
  `cli`/`updater` boolean env-toggle parsers). All new fuzz targets ran
  crash-free; the full proptest suite (64 properties) and the full
  workspace test suite pass with the changes.

## [1.6.40] - 2026-07-08

### Added

- **Maturity level on annotations.** The annotation editor
  (`/annotations`) now has a **Maturity Level** picker between the Modal and
  Status controls, with the six CMMI-style levels — 0 Incomplete, 1
  Performed, 2 Managed, 3 Established, 4 Predictable, 5 Innovating. The
  default is **Level 1 — Performed**. Each saved version stores its level (a
  new `maturity` column on the `versions` table, added to existing databases
  by an idempotent migration); the picker pre-selects the shown version's
  level. `Store::save` keeps its signature and defaults to Performed;
  `Store::save_with_maturity` is the new explicit path.

### Security

- **Upload malware-scan fail-open closed (`scan.rs`).** The scanner subprocess
  wrapper piped stdout but never drained it, so a scan that wrote more than the
  OS pipe buffer (~64 KiB) — e.g. a YARA run reporting many matches — blocked on
  its own write, was force-killed at the 30 s deadline, and treated as **clean**,
  admitting exactly the most heavily-flagged uploads. stdout is now drained on a
  dedicated thread so the child finishes and its exit code decides the verdict.
  Advisory `ndaal-sa-2026-234`.
- **`GSV_ALLOW_NON_LOOPBACK` is now trimmed (`main.rs`).** A whitespace-polluted
  falsy value such as `"0\n"` (from a heredoc or file) compared unequal to `"0"`
  and silently opted into a non-loopback bind, weakening the loopback safety
  guard. The value is trimmed before the `""`/`"0"` check. Advisory
  `ndaal-sa-2026-238`.
- **Word-form falsy env values now respected (`cli.rs`, `main.rs`).** Building on
  the trim fix above, `GSV_ALLOW_NON_LOOPBACK=false` (or `no`/`off`) still lifted
  the loopback guard, because the only excluded values were `""` and `"0"`. Every
  boolean `GSV_*` toggle now parses through one helper (`cli::env_flag_on`) whose
  falsy set is `{"", "0", "false", "no", "off"}` (case-insensitive, trimmed), so
  an operator writing `=false` to *disable* the opt-out no longer accidentally
  enables it. The same helper governs `GSV_NO_FETCH_YARA_RULES`. Advisory
  `ndaal-sa-2026-239`.
- **Meilisearch HTTP exchanges are now time-bounded (`meili.rs`).** The optional
  Meilisearch client had no wall-clock timeout, so a backend that accepted the
  connection but never replied (or stalled mid-body) hung the `/search` handler
  forever — and the documented fall-back to the built-in engine, which only
  fires on a returned error, never engaged. Every exchange (connect + send +
  receive) is now capped at 10 s, so a stall degrades to the built-in engine.
  Meilisearch is opt-in (`--meili-url`). Advisory `ndaal-sa-2026-244`.

### Fixed

- **NUL bytes survive a SQL dump/restore (`store.rs`).** A note body with a NUL
  was emitted as a raw quoted literal, which terminates SQL parsing and made
  the whole `.sql` dump unrestorable (silently-corrupt backup). NUL-bearing text
  is now emitted as a hex `CAST(x'…' AS TEXT)`, as `sqlite3 .dump` does, so it
  round-trips. Advisory `ndaal-sa-2026-235`.
- **Multi-member `.sql.gz` restore no longer drops rows (`dbdump.rs`).**
  `GzDecoder` decoded only the first gzip member of a concatenated stream and
  reported success; switched to `MultiGzDecoder` so every member is restored.
  Advisory `ndaal-sa-2026-236`.
- **A `*.md` subdirectory no longer aborts template import (`template_import.rs`).**
  A directory or symlink named `*.md` passed the name filter, then failed
  `dir.read()`, and that error aborted the entire import permanently on every
  start-up. Enumeration now skips non-regular-file entries. Advisory
  `ndaal-sa-2026-237`.
- **A malformed `GSV_PORT` is now a hard error (`cli.rs`, `main.rs`).** A typo
  like `GSV_PORT=8443x`, or an out-of-range value, was silently discarded and the
  server bound the *default* port, so the operator believed the service was on one
  port while it listened on another. `GSV_PORT` now parses through
  `cli::parse_port_env`, which errors (naming the bad value) instead of defaulting
  — mirroring how an invalid `--port` flag is already rejected. Advisory
  `ndaal-sa-2026-240`.
- **The accept loop no longer busy-spins on a persistent accept error (`main.rs`).**
  A failed `accept()` was logged and retried immediately; under descriptor
  exhaustion (EMFILE/ENFILE) that spun a CPU core at 100% and starved request
  handling. A 50 ms back-off is now applied after a failed accept. Advisory
  `ndaal-sa-2026-241`.
- **CSV mapping parser keeps carriage returns inside quoted fields (`mappings.rs`).**
  `split_csv_records` dropped every `\r` to tolerate CRLF line endings, which also
  deleted a `\r` that was *data* inside a quoted, multi-line value. `\r` is now
  dropped only outside quotes; inside a quoted field it is preserved, so an
  embedded CRLF round-trips intact. Advisory `ndaal-sa-2026-242`.
- **`--log-format` now overrides `GSV_LOG_FORMAT` (`cli.rs`, `main.rs`).** The flag
  and the env var were tested independently, so `--log-format text` did not
  override `GSV_LOG_FORMAT=json` — inconsistent with every other flag/env pair.
  The decision now routes through `cli::json_log_format`, where the flag wins.
  Cosmetic (log output only); advisory `ndaal-sa-2026-243` (CVSS 0.0).
- **Meilisearch is marked ready only once its documents are queryable (`meili.rs`).**
  Indexing enqueues an async task (HTTP 202) but `meili_ready` flipped `true`
  immediately, so a search in the brief startup window returned an empty 200 —
  which, not being an error, did not fall back to the built-in engine.
  `index_documents` now polls the enqueued task to `succeeded` before returning.
  Advisory `ndaal-sa-2026-245`.
- **Annotation search no longer matches superseded revisions or misses umlaut
  queries (`store.rs`).** The unencrypted search path matched any revision via
  SQL `instr(lower(...))` (ASCII-only) while the encrypted path matched only the
  latest revision in Unicode-aware Rust — so results diverged by store type: a
  hit could point at a page that no longer contained the term, and `MÜLL` failed
  to match `müll`. Both store types now share one latest-revision, Unicode-folded
  path. Advisory `ndaal-sa-2026-246`.
- **A failed same-stamp database dump no longer deletes a prior dump (`dbdump.rs`).**
  Cleanup after a partway-failed dump removed every `annotations-<stamp>.*` file
  by prefix glob, so a second dump that collided on the same timestamp failed on
  the no-clobber snapshot and then deleted the earlier, completed dump. Cleanup
  now snapshots the directory before writing and removes only files this call
  created (its stamp prefix AND absent beforehand). Advisory `ndaal-sa-2026-247`.
- **Capped annotation search returns the most recent matches (`store.rs`).** The
  optional local search returned up to 50 matches ordered by ascending control
  id with no surfaced total, so a note just edited on a higher-id control was
  silently pushed out of the capped results. Results are now ordered most-
  recently-edited first (`created_at`, then the monotonic version id), so the cap
  keeps the newest matches; the doc comment now matches. No data loss, no
  security impact. Advisory `ndaal-sa-2026-248` (CVSS 0.0).

### Changed

- **`/controls` text filter hoists its needle out of the scan (`filter.rs`).**
  `matches_text` re-lowercased and re-tokenised the constant query string once
  per catalogue entry (~998 short allocations per `q=` request). `apply` now
  lowercases/tokenises once and reuses it; both the single-entry and bulk paths
  share one tokeniser so they cannot diverge. Behaviour-identical (pinned by
  `filter::apply_matches_the_per_entry_predicate_including_text`); perf only.
- **`--seed-demo` now reproduces the exhaustive fifteen-version editor sequence
  (`demo.rs`).** Previously each target got a light three-version note with one
  generated badge. It now mirrors `tests/test_annotation_sequence.rs`: a
  fifteen-version history where V1–V9 interleave the eight real unDraw graphics
  (art / around-the-world / airport / analysis, SVG + PNG) with the five
  reusable templates (QS checklist, arc42 DE/EN, MADR DE/EN), and V10–V15 walk
  the CMMI maturity level (2 → 3 → 4 → 5 → 1 → 4); V2 and V15 are released, the
  rest drafts. The graphics and templates are embedded
  (`include_bytes!`/`include_str!`), so the binary stays self-contained. Because
  assets are stored per target, a full ~1000-control seed is large (~1 GB) —
  `--seed-demo` remains an explicit opt-in and is non-destructive (existing
  annotations are never touched).

### Tests

- **Regression tests for the security/correctness fixes above**
  (`scan::run_capturing_drains_large_stdout`, `store::sql_dump_round_trips_a_nul_byte`,
  `dbdump::gunzip_capped_decodes_a_concatenated_multi_member_gzip`,
  `template_import::a_subdirectory_named_dot_md_does_not_abort_the_import`,
  `cli::env_flag_on_treats_falsy_and_whitespace_as_off`,
  `cli::parse_port_env_errors_on_garbage_and_never_silently_defaults`,
  `cli::json_log_format_lets_the_flag_override_the_env`,
  `mappings::split_csv_records_keeps_a_cr_inside_a_quoted_field`,
  `dbdump::a_failed_same_stamp_dump_preserves_a_prior_dumps_files`,
  `store::search_returns_the_most_recently_edited_matches_within_the_limit`).
- **De-flaked `template_import::over_cap_directory_is_truncated_to_the_limit`.**
  The truncation-guard test drove the production `import_from_dir`, spawning ~500
  external ClamAV/YARA subprocesses; under the parallel suite that surfaced as a
  spurious `dir.read` `NotFound`. It now injects an always-clean scanner via
  `import_from_dir_with` — same code path, deterministic and fast — since the
  malware scan is irrelevant to the guard it pins.
- **Regression tests for the Meilisearch and search fixes**
  (`meili::timed_turns_an_over_budget_stall_into_a_meili_error`,
  `meili::timed_passes_a_ready_future_through_unchanged` — under a paused tokio
  clock; `meili::parse_task_state_classifies_meilisearch_statuses`,
  `meili::parse_task_uid_reads_the_enqueue_response`,
  `store::search_matches_only_the_latest_revision_and_folds_unicode_case`).
- **Bruno endpoint coverage.** The `test/bruno` dev collection now exercises
  every router endpoint. A new `collections/core/` group covers the read and
  JSON surfaces (`/`, `/controls` + practice filter, `/control/{id}` +
  `raw.json` + 404, `/practices`, `/practice/{id}`, `/stats`, `/metadata`,
  `/catalog.json`, `/search` + `/search/partial`) and a new
  `collections/info/` group covers the remaining info dialogs (`/about`,
  `/license`, `/system-info`, `/privacy`, `/security`, `/imprint`,
  `/changelog`). The annotations save test now sends and asserts the new
  `maturity` field round-trips. Full suite: 43 requests, 159 assertions,
  green against the development environment.
- **Property + fuzz coverage for maturity.** `tests/proptest_invariants.rs`
  gains two properties: `Maturity::from_token` is total and its digit tokens
  are disjoint from the `TargetKind`/`Modal`/`Status` word tokens, and every
  level round-trips through `save_with_maturity`. `fuzz_store_tokens` now
  includes `Maturity` in its four-way disjointness invariant, and a new
  `fuzz_store_maturity` target fuzzes the maturity-carrying save/read path
  (both build clean; 45s smoke run, 1925 execs, no crash).
- **Playwright maturity coverage.** The annotation-editor spec now selects a
  non-default level, saves, and asserts it persists on reload, plus a new
  test that the picker offers all six CMMI levels in order. 9/9 green across
  chromium, firefox, and webkit.

## [1.6.39] - 2026-07-06

### Changed

- **The full framework crosswalk is now embedded in the binary (139 frameworks,
  ~181k mapping rows).** Previously only the hand-curated GitHub crosswalk (23
  controls) shipped in-binary; the broad generated pack was runtime-only via
  `--mappings-dir`, so a viewer started without that flag showed mappings for a
  single framework and every other Settings toggle appeared to do nothing. All
  139 generated CSVs are now embedded under `data/mappings/` (Apache-2.0 —
  ndaal's own similarity-analysis work product; see `data/mappings/README.md`),
  so every control shows its cross-framework mappings out of the box — no
  `--mappings-dir` required. Guarded by a new
  `embedded_pack_covers_the_whole_framework_catalogue` test (asserts ≥130
  frameworks embedded and ≥40 on GC.1.1), which fails loudly if the pack is ever
  dropped again.

### Fixed

- **Framework toggle buttons (All / None / Reset, and each switch) did
  nothing in browsers that block `localStorage`.** The Settings JS persisted
  the per-browser override map with an unguarded `localStorage.setItem` /
  `removeItem`, and only called `applyMappingVisibility()` *after* that write.
  In a private-mode / strict-privacy / storage-disabled profile the write
  throws, so the click handler aborted before the DOM updated — the mappings
  never showed or hid (the "reset → all → nothing" report). All `localStorage`
  access is now wrapped in `try`/`catch` with an in-memory fallback, so the
  toggles work for the current session even when persistence is unavailable;
  when storage works, behaviour is unchanged. (The inline-script CSP hash was
  recomputed accordingly.)
- **Settings framework toggles rendered with no visible on/off state** — the
  Content-Security-Policy `img-src` directive was `'self'`, which blocked the
  23 `data:image/svg+xml` icons the bundled Bootstrap CSS uses (the
  form-switch knob, checkbox check, and select/dropdown chevrons). Every one
  of the 156 Settings toggles therefore showed a bare track with no slider,
  making it impossible to tell which frameworks were on or off (it looked like
  "all toggles are on"), and the browser console filled with CSP errors. The
  directive is now `img-src 'self' data:`, restoring the icons. `data:` images
  are inert (no script execution, no cross-origin fetch), so this does not
  weaken the policy — it matches `font-src`, which already allows `data:`. The
  `'self'`-only rule was collateral tightening from the audit-2.10 hardening in
  0.1.23; the CSP had originally shipped as `img-src 'self' data:`. `style-src`
  stays `'self'` (no `'unsafe-inline'`).

## [1.6.38] - 2026-07-06

### Added

- **Self-update (`--check-update` / `--self-update`)** — the binary can now
  check `gitlab.com/vPierre/ndaal_public_bsi_grundschutz_oscal_viewer` for a
  newer release and replace itself. `--check-update` reports the result
  (read-only; an unreachable host is reported, never fatal); `--self-update`
  downloads the matching target-triple binary, **verifies its SHA-256 against
  the committed `release/SHA256SUMS` at the tag**, and atomically swaps the
  running executable. Both are one-shot early actions that exit before the
  server starts. Built on the [`self_update`](https://crates.io/crates/self_update)
  crate (`1.0.0-rc.2`, GitLab backend) but with a **custom HTTP transport** so
  every request rides our own aws-lc-rs, TLS-1.3-only hyper client — no second
  crypto provider, no TLS 1.2, no `reqwest`/`openssl` (see `src/updater.rs`).
  Never downgrades (a from-source build ahead of the latest release is left
  alone) and never installs on a checksum mismatch. The pure surfaces
  (`asset_name`, `is_newer`, `parse_sha256sums`) carry unit tests, `proptest`
  invariants, and three `cargo-fuzz` targets; the network paths are driven by a
  canned transport mirroring the crate's own `tests/custom_transport.rs`.
  `--self-update` **pins the exact release tag** it checksummed
  (`.release_tag(v<version>)`) so self_update installs precisely the version the
  hash was computed for — it can never install one release and verify against
  another's checksum. The body-download phase is time-bounded and redirect
  targets may not downgrade to `http://`.
- **Self-update opt-out (`--no-self-update` / `GSV_NO_SELF_UPDATE`)** — a policy
  switch for package-managed or locked-down installs: when set, `--self-update`
  refuses (exit non-zero) so the binary never replaces itself; `--check-update`
  (read-only) stays available.
- **Release-pipeline self-update contract check** —
  `release/verify_selfupdate_artifacts.sh` (wired as step 3c of
  `scripts/release_pipeline.sh`, toggle `RP_SKIP_SELFUPDATE_VERIFY`) asserts
  that every one of the six current-version release assets is present, listed by
  its exact name in `release/SHA256SUMS`, and checksum-matched — so a released
  binary can always find and verify its successor. Self-skips when the binaries
  are not staged yet.
- **`documentation/tutorials/`** — eight task-oriented how-tos plus an index
  README: cutting a release, bumping the version, writing a CSAF advisory,
  running the quality gates, authoring a `test_*.sh` bash gate, backing
  up/restoring the SQLite database, verifying TLS 1.3 / PQC, and running the
  security scans. Each is grounded in the real repo scripts (agents caught and
  corrected several sibling-project details carried over in the topic brief),
  with a "Known gotchas" and a "Verification" section; both markdown linters
  pass.
- **README "Documentation" index** (`README.md` + `LIESMICH.md` +
  `LISEZMOI.md`) — a new table linking the tutorials, the user and
  administrator guides, the arc42 architecture view, the framework mappings
  pack, the test sequences, and the changelog. Only docs that exist are
  linked (no `developer_guide` / `troubleshooting` / STRIDE / `api` /
  `structure` — those are not in this repo).

## [1.6.37] - 2026-07-06

### Added

- **TISAX (VDA ISA) 2027 crosswalk pack** (`import/vda-isa-2027-mappings.csv`)
  — a hand-curated, 77-control mapping from VDA's ISA 2027 questionnaire to
  Grundschutz++, built by reading both catalogs directly (no `score` values,
  unlike the auto-generated CISO Assistant packs). New framework slug
  `tisax-vda-isa-2027` in `data/frameworks.csv`, distinct from the existing
  `tisax-vda-isa-v5-1-and` entry since the 2027 questionnaire's control
  numbering doesn't match v5.1/v6.0. Ships as a runtime `--mappings-dir` pack,
  not embedded in the binary, since VDA's questionnaire is their own
  proprietary content. Documented in `documentation/framework_mappings_pack.md`
  §6.

- **`scripts/generate_ciso_mappings/`** — a new, real, rerunnable generator
  for the full CISO Assistant-derived `--mappings-dir` pack described in
  `documentation/framework_mappings_pack.md` §4 (previously a design only,
  never implemented). Downloads every framework library YAML from
  [CISO Assistant](https://github.com/intuitem/ciso-assistant-community)
  (AGPLv3), matches `data/frameworks.csv` slugs to library files (a
  token-overlap heuristic plus a small hand-verified override/exclude table
  — see `matching.py`), extracts the Grundschutz++ catalog and every
  matched library's assessable requirements (preferring German
  translations — see `extraction.py`), then embeds both sides with
  `paraphrase-multilingual-mpnet-base-v2` and writes one top-k
  cosine-similarity CSV per framework (see `embedding.py`). First full run:
  **139 of 156 frameworks matched, ~181k mapping rows across 138
  non-empty CSVs** (1 framework — a narrow vendor SCRM checklist — had no
  confident match above the similarity floor; 17 were deliberately
  excluded rather than shipped with a wrong match). The generator's own
  output is AGPLv3-derived data and is never committed to this repo or
  embedded in the binary — see its README.md for the platform note on
  pinning `torch==2.2.2` (the last release with macOS x86_64/Intel wheels).

- **Property-based (`hypothesis` — the Python analog to Rust's `proptest`)
  and fuzz-style tests for every parser of untrusted external input** in
  `scripts/generate_ciso_mappings/` (the CISO Assistant YAML, its GitHub
  tree-API listing, and `data/frameworks.csv` itself). Found and fixed
  4 real crashes, all the same root cause — code assumed a YAML/JSON field
  was a string when malformed input could make it `null`, a bool, or a
  nested mapping: a CSV row with fewer than 2 columns (`IndexError`), a
  `translations` field present but `null` (`AttributeError` — the classic
  `dict.get(key, default)` gotcha, where `default` only applies when the
  key is *absent*, not when its value is falsy), and the same gotcha on
  `urn` and on a non-dict GitHub tree entry. Fixed with one `_as_str()`
  coercion helper used consistently. **45 tests total**
  (`pytest scripts/generate_ciso_mappings/tests/`), all offline, no
  network or ML dependencies required except for the embedding smoke test
  covering the matching/extraction logic offline, no network or ML
  dependencies required.

- **`.markuplintrc.yaml`** — the `markuplint` quality gate now parses the
  Askama templates with `@markuplint/nunjucks-parser` (its default HTML
  parser cannot read `{% %}` / `{{ }}` / `{# #}` and mis-parsed every
  file), cutting **151 false findings to 0**. The remaining suppressions
  are documented and narrowly scoped in the config — HTMX `hx-*` custom
  attributes, Askama conditional attributes
  (`{% if x %}checked{% endif %}`), the standard Bootstrap tablist/switch
  ARIA patterns, and the layout's absent `<h1>` (each child page supplies
  its own). Requires `npm i -g markuplint @markuplint/nunjucks-parser`;
  the gate self-skips cleanly (rc=0) when the parser package is absent.

### Changed

- **Framework toggle defaults** (`data/frameworks.csv`): **GitHub Security
  Controls is now off by default** — it ships the only *embedded* crosswalk
  data, but a fresh page now opens on the Stand-der-Technik / regulatory
  frameworks rather than the repository-hosting layer. **EU AI Act**,
  **ISO 42001:2023** (AI Management System) and **IEC 62443** are now
  **on by default**. Default-on count 12 → 14. Note: with the embedded
  (no `--mappings-dir`) build, a fresh page load now shows no visible
  mapping rows until a toggle is enabled, since the only framework with
  embedded data is off by default.

### Fixed

- **Template accessibility / HTML validity**, surfaced once markuplint
  could actually parse the templates: the navbar `<img>` carries explicit
  `width`/`height` (prevents layout shift); the Bootstrap, HTMX and
  annotation scripts are `defer`red; a `<form>` invalidly nested inside a
  `<span>` on the export page is now a `<div>`; and a menu-label `<h6>`
  that skipped heading levels is now a `<div class="dropdown-header">`.

### Tests

- **Playwright E2E suite wired into `scripts/quality_gates.sh`** as the
  `playwright` gate (a `RUNNER_OVERRIDE` entry resolving to
  `test/playwright/run.sh`), alongside `settings_toggle`/`screenshots` in the
  App-specific LIVE group — skipped by `--fast`, run in the full sweep or via
  `--only playwright`. The suite manages its own throwaway `--seed-demo`
  server instance, so it has no dependency on a prior "restart the dev
  server" step.
- **Expanded Playwright toggle coverage**: `settings-frameworks.spec.ts`
  now exercises every mapping-toggle action explicitly — a **single**
  framework toggle (with per-framework isolation and reload persistence),
  **All**, **None** and **Reset** — plus the toggle-list search box. The
  bulk assertions read each toggle's server-rendered `data-default`
  dynamically, so the suite stays correct regardless of which frameworks
  are default-on, and scales from the embedded-only crosswalk up to a full
  `--mappings-dir` pack. Verified live on chromium against a real
  `--seed-demo` release build (5/5 green).

## [1.6.36] - 2026-07-04

### Added

- **Exhaustive Playwright E2E suite** (`test/playwright/`) — 10 spec files
  driving a real Chromium, Firefox, and WebKit against a throwaway
  `--seed-demo` instance the suite manages itself, covering HTMX live
  search, framework-toggle `localStorage` persistence, the language-switcher
  cookie/redirect flow, annotation create/preview/save, and the security-header
  baseline over a real TLS 1.3 handshake — behaviour the in-process Rust
  router tests cannot exercise. `playwright.config.ts` starts from a
  hardened, RFC-aligned baseline (fail-fast `BASE_URL`/`tsconfig.json`
  checks, TLS-version enforcement in `global-setup.ts`, locked-down browser
  context permissions/CSP/sandbox) with two deliberate deviations documented
  inline: `ignoreHTTPSErrors: true` (the throwaway instance's certificate is
  always self-signed by design) and `workers: 1` (every test, across all
  three browser projects, shares one server and its one SQLite database).
  Documented in `documentation/test_sequences.md` §7 and this file's
  README/LIESMICH/LISEZMOI Development sections. Not yet wired into
  `scripts/quality_gates.sh` — run directly via `test/playwright/run.sh`.

### Fixed

- **Bug-hunt round: 6-lens parallel discovery + 3-skeptic adversarial
  verification found 9 candidates; 8 were confirmed genuine and fixed, 1 was
  investigated and found to be a false positive against an existing,
  deliberately fuzzed design decision.** Each is recorded as its own CSAF 2.1
  advisory (`ndaal-sa-2026-163` .. `-171`) with CVSS v3.1/v4.0 scoring.
  - **Encrypted-search annotation lookup ran on the async executor thread,
    not the blocking pool.** `annotation_hits()` (`src/routes/search_page.rs`)
    called `Store::search_annotations` synchronously from the async request
    handler; for an encrypted store this runs the lock-held decrypt scan
    directly on a Tokio worker, unlike every comparable blocking operation
    elsewhere in the codebase. Now wrapped in `tokio::task::spawn_blocking`,
    matching the established convention. (`ndaal-sa-2026-163`)
  - **A quoted multi-line CSV field in a `--mappings-dir` pack was torn into
    two records before quote-awareness.** `collect_csv` (`src/mappings.rs`)
    split the document via `str::lines()` before the quote-aware field
    splitter ran, so a legally-quoted field containing a literal newline
    (as commonly produced by spreadsheet CSV export) truncated or dropped
    the row. Added `split_csv_records()`, a quote-aware record-boundary
    scanner, used by both `collect_csv` and (defensively) `frameworks::parse`
    — the latter has no live external-input path today since
    `data/frameworks.csv` is compile-time embedded. (`ndaal-sa-2026-165`,
    `-166`)
  - **`--restore-dump` left a stale destination file after a failed SQL
    replay, blocking the operator's very next retry.** `rusqlite`'s
    `Connection::open` creates the destination SQLite file immediately, before
    any statement runs, so a mid-batch SQL error left an empty/partial file
    that the no-clobber guard then rejected on retry. `restore_dump` now
    removes `dest` (best-effort) on failure — safe because `dest` is confirmed
    absent at function entry. (`ndaal-sa-2026-167`)
  - **A dump failure after the `VACUUM INTO` snapshot was written left an
    orphaned `.db` file with no matching `.sql`/sidecars, and could block a
    same-stamp retry.** `run_dump` (`src/dbdump.rs`) now sweeps every file
    matching the dump's unique `annotations-<stamp>` prefix on any failure —
    safe because `stamp` is unique per call. (`ndaal-sa-2026-169`)
  - **A rename failure partway through publishing a fetched YARA rule set
    left already-published new rule files un-rolled-back.** `commit_files`
    (`src/yara_fetch.rs`) now tracks which final rule names were brand new
    and removes any already-published new file on a later rename failure,
    restoring the pre-call rule set. A rename that overwrote a pre-existing
    rule is left as its updated content (the prior bytes are already gone the
    instant that rename succeeds, so removal would delete the rule rather
    than restore it). (`ndaal-sa-2026-168`)
  - **`Accept-Language` `q=0` exclusions (RFC 9110 §12.5.4) were ignored.**
    `lang_from_accept` (`src/i18n.rs`) picked the UI language purely by list
    order and never inspected `q`-values; a client explicitly excluding a
    language via `q=0` (e.g. `de;q=0, fr;q=0.9` — "never German") could still
    be served it. Now the one weight value that changes acceptability
    outright is honoured, while every other weight remains unparsed (the
    existing no-float-sort design). (`ndaal-sa-2026-171`)
  - **`--help` documented a stale pre-1.5-era default path for `--db`.** The
    entry still read `<home>/grundschutz-oscal-viewer/annotations.db`; the
    real default had moved to `<data-dir>/annotations.db`
    (`./ndaal/data/annotations.db`) — already correct in the sibling
    `--data-dir`/`--restore-dump` entries. Documentation only, no functional
    impact. (`ndaal-sa-2026-170`)
  - **Investigated: `split_csv_line` trims quoted CSV fields.** Confirmed
    intentional, not a defect — `fuzz/fuzz_targets/fuzz_csv_split.rs` asserts
    it as an explicit, already-fuzzed invariant (every field, quoted or not,
    equals its own `trim()`). No behaviour changed; the doc comment was
    clarified. (`ndaal-sa-2026-164`, CVSS 0.0/NONE)

### Tests

- Nine new regression tests pin the bug-hunt fixes:
  `collect_csv_keeps_a_quoted_multiline_title_as_one_record`,
  `parse_keeps_a_quoted_multiline_display_as_one_record`,
  `accept_language_excludes_q_zero_languages`,
  `db_flag_help_text_matches_the_actual_resolved_default`,
  `restore_dump_cleans_up_dest_after_a_failed_sql_replay_so_retry_can_proceed`,
  `run_dump_removes_the_snapshot_when_a_later_step_fails`,
  `commit_files_rolls_back_new_files_when_a_later_rename_fails`, and
  `annotation_hits_still_finds_matches_through_the_blocking_pool`.
- **Closed the remaining proptest/fuzz coverage gaps surfaced by the bug-hunt
  fixes.** Four new `proptest` invariants in `tests/proptest_invariants.rs`:
  `mapping_csv_never_panics_and_frameworks_are_clean` (the mapping-pack CSV
  parser had a fuzz target but no proptest counterpart, unlike
  `frameworks::parse`), `split_csv_records_never_panics` and
  `split_csv_records_keeps_a_quoted_embedded_newline_in_one_record` (the new
  record-boundary scanner), and `accept_language_q_zero_is_never_selected`
  (the RFC 9110 `q=0` exclusion, generalised across all three supported
  languages). `fuzz/fuzz_targets/fuzz_restore_dump.rs` gained a new
  assertion — a failed restore must never leave a stale `dest` file behind —
  alongside its existing no-panic invariant. `run_dump`/`commit_files`
  remain unit-test-only: both need a live `Store`/populated directory to
  exercise, which fits proptest/fuzz's byte-and-string generators poorly;
  their existing regression tests already force the exact failure paths
  deterministically. Verified: all 40 proptest invariants and a 20s fuzz
  smoke run (zero crashes across ~370k combined executions) on every
  target whose underlying parser changed
  (`fuzz_restore_dump`/`fuzz_csv_split`/`fuzz_mapping_csv`/
  `fuzz_frameworks_parse`/`fuzz_lang_resolve`).
- **Mutation-testing sweep over the remaining unswept lib modules**
  (`cargo-mutants`, three parallel isolated-worktree runs): `filter.rs` (70
  mutants) and `sidecar.rs` (31 mutants) came back clean — zero surviving
  mutants. `mappings.rs` (50 mutants) surfaced 4 real coverage gaps, closed
  with 3 new tests: `MAX_MAPPING_FILE_BYTES`'s `16 * 1024 * 1024` literal had
  no test asserting its actual value (two `*`→`+` mutations survived,
  shrinking the 16 MiB cap unnoticed); `read_capped`'s `>` boundary had no
  exact-at-the-cap test (a `>`→`>=` mutation would silently reject a file
  whose size exactly equals the cap); `split_csv_records`'s trailing-record
  flush had no unterminated-CSV test (a `!`-deletion mutation would silently
  drop the last row of any mappings CSV lacking a final newline). All four
  are coverage gaps in already-correct code, not live bugs — no CSAF
  advisory.

## [1.6.35] - 2026-07-02

### Security

- **Adversarial security review (5 parallel auditors) + two hardening fixes.**
  A five-agent review swept the whole attack surface — HTTP request/response
  layer, upload/markdown/XSS, filesystem/path/zip-slip, SQL/store/crypto, and
  TLS/clients/DoS/subprocess. **No remotely-exploitable weakness was found**
  (header injection, XSS, CSRF-gate bypass, path traversal, zip-slip, SQL
  injection, command injection, SSRF, and TLS-verification bypass are all
  closed). Two genuine low-severity hardening gaps were fixed:
  - **Encrypted-search memory-amplification (DoS).** `Store::search_encrypted`
    materialised *every* latest annotation body into memory before applying the
    caller's `limit` (SQL can't filter through ciphertext). It now streams
    row-by-row and stops at `limit` matches, bounding peak allocation to the
    matched set instead of the whole annotation set. Regression test added.
  - **curl argv-option-smuggling (defense-in-depth).** The YARA-rules fetcher
    passed the download URL as a positional arg after `--output -`; a
    `-`-leading URL could be read as a curl flag. Added a `--` terminator (the
    pre-existing `https://` prefix check already excluded it).
- **Two tracked low-severity residuals documented** (deliberately deferred, not
  overlooked): (a) the encryption-at-rest AEAD binds no associated data, so an
  attacker with *write access to the SQLite file* could relocate a sealed value
  between rows — no keyless plaintext exposure; the correct per-row-AAD fix
  needs a versioned `enc:v2` format with a v1-legacy read path, whose
  data-integrity migration risk is not justified by the severity in a routine
  pass (see `src/crypto.rs`); (b) `dump_download_response` reads a validated
  dump filename via `std::fs` rather than a cap-std handle — an operator-local
  TOCTOU with no remote primitive (the dump dir is written only by the server).
- Recorded as CSAF 2.1 advisory `ndaal-sa-2026-161` (**CVSS v3.1 2.9 / v4.0 2.1
  — Low**, availability-only, opt-in, loopback-default), with the release
  announcement `ndaal-sa-2026-162`. Both ship the five hash sidecars and pass
  `csaf-validator --test basic`.

### Changed

- **Version bump 1.6.34 → 1.6.35** to carry the security-review hardening above
  on top of the 1.6.34 dependency-currency cut. No other source changes.

## [1.6.34] - 2026-07-02

### Changed

- **Dependency-currency refresh — no first-party source changes.** Three
  semver-compatible dependency bumps picked up via `cargo update` (respecting
  the existing manifest requirements):
  - `time` 0.3.52 → 0.3.53 (direct; X.509 validity time)
  - `arrayvec` 0.7.7 → 0.7.8 (transitive via `blake3`, the BLAKE3-512
    checksum-sidecar dependency)
  - `console` 0.16.3 → 0.16.4 (transitive dev-dependency via `insta`,
    snapshot testing only — never ships in the release binary)
  `sha3` was again deliberately **held** at 0.11 — 0.12 drops the SHAKE/XOF
  the checksum sidecars need.

### Security

- Published three CSAF 2.1 advisories recording the dependency-currency
  bumps — `ndaal-sa-2026-158` (time), `-159` (arrayvec), `-160` (console) —
  each scored **CVSS v3.1 0.0 / v4.0 0.0 (NONE)**. None of the superseded
  versions carried a RUSTSEC advisory (`cargo deny check` = advisories ok).
  Every advisory ships the five hash sidecars (SHA-256/512, SHA3-512,
  BLAKE3-512, SHAKE256-512) and passes `csaf-validator --test basic`.
- **Mirrored the tracked `ttf-parser` unmaintained ignore
  (RUSTSEC-2026-0192) into `audit.toml`.** The 1.6.32 triage added the
  documented ignore only to `deny.toml`; `cargo audit` (a separate gate with
  its own config) still failed on the same already-triaged finding. Both
  advisory gates now agree, and `tests/scripts/test_cargo_audit.sh` reports
  clean — 0 advisories at deny-warnings/unsound/unmaintained/yanked.

### Tests

- **Closed the last property/fuzz coverage gap: `--encryption-key-file`
  loading.** Extracted the byte-parsing core of `crypto::load_key_file`
  (raw-32-bytes-or-64-hex-chars resolution) into a new `pub fn
  resolve_key_bytes`, file-I/O-free so it can be fuzzed and property-tested
  directly — mirroring how `tls::parse_cert_chain`/`parse_private_key` are
  structured. Added two proptest invariants (panic-freedom + exact 32-byte
  round-trip; hex acceptance regardless of case or surrounding whitespace)
  and a new `fuzz_load_key_file` cargo-fuzz target (60s smoke run: 76,379
  execs, 0 crashes). `load_key_file` itself is unchanged in behaviour — a
  pure extraction, verified against its existing unit tests. proptest
  invariants 34 → 36; fuzz targets 40 → 41. A systematic pub-fn audit found
  no other untrusted-input parser lacking coverage.
- Fixed two pre-existing `clippy -D warnings` failures in `fuzz/` surfaced by
  the Rust 1.96.1 toolchain (`fuzz_mapping_csv.rs`'s deliberate
  `len() == 0`/`is_empty()` consistency check needed a targeted
  `#[allow(clippy::len_zero)]` with a WHY comment — the literal lint rewrite
  would have made the assertion compare `is_empty()` to itself;
  `fuzz_sidecar_selection.rs`'s split closure simplified to
  `split(['&', ',', ' ', '='])` per `manual_pattern_char_comparison`, a
  behaviour-preserving rewrite).

## [1.6.33] - 2026-07-01

### Changed

- **Build toolchain: Rust 1.96.1.** The release is compiled on Rust 1.96.1 (up
  from 1.96.0); the declared MSRV (`rust-version = "1.93"`) is unchanged. The
  `--release` build succeeds cleanly on the new toolchain and the running viewer
  restarts healthy (HTTPS 200).
- **Dependency-currency refresh — no first-party source changes.** Five
  semver-compatible dependency bumps picked up via `cargo update` (respecting the
  existing manifest requirements); the tree resolves unchanged, `cargo metadata`
  succeeds, and the TLS 1.3 (rustls + aws-lc-rs) behaviour is identical:
  - `time` 0.3.51 → 0.3.52 and `time-macros` 0.2.30 → 0.2.31
  - `aws-lc-sys` 0.41.0 → 0.42.0 and `aws-lc-rs` 1.17.0 → 1.17.1 (the rustls
    aws-lc-rs CryptoProvider that backs TLS 1.3)
  - `rustls-pki-types` 1.14.1 → 1.15.0 (shared PKI types + the `pem` loader used
    by `src/tls.rs` / `src/meili.rs`)
  - `sha3` was deliberately **held** at 0.11 — 0.12 drops the SHAKE/XOF that the
    checksum sidecars need.
  The library, binary, embedded catalog and templates are unchanged from 1.6.32
  apart from the version string and the refreshed `Cargo.lock`.

### Security

- Published five CSAF 2.1 advisories recording the dependency-currency bumps —
  `ndaal-sa-2026-152` (time), `-153` (time-macros), `-154` (aws-lc-sys), `-155`
  (aws-lc-rs), `-156` (rustls-pki-types) — each scored **CVSS v3.1 0.0 / v4.0 0.0
  (NONE)**. None of the superseded versions carried a RUSTSEC advisory
  (`cargo deny check` = advisories ok), so these document supply-chain currency,
  not a fix. Every advisory ships the five hash sidecars (SHA-256/512, SHA3-512,
  BLAKE3-512, SHAKE256-512) and passes `csaf-validator --test basic`.

## [1.6.32] - 2026-06-30

### Security

- **`cargo deny` advisory ignore: `ttf-parser` unmaintained
  (RUSTSEC-2026-0192).** A newly-published RUSTSEC advisory flags `ttf-parser`
  (the PDF-export font parser, a direct dep) as **unmaintained** — no CVE, no
  vulnerability. With no maintained drop-in replacement, it is ignored in
  `deny.toml` with a documented, tracked reason (per the file's "document reason
  per entry" policy), so `cargo deny check` passes again. Revisit / replace when
  a maintained alternative exists. `cargo audit` was unaffected (it had not yet
  picked up the advisory).
- **Path-security hardening (audit follow-up; defence-in-depth, no
  network-reachable vulnerability).** A full filesystem path-handling audit
  confirmed containment is in place everywhere — the network-reachable FS
  surface is an embedded static-asset table plus one `dump_file_in`-validated
  download, and every operator/remote-controlled path goes through a cap-std
  capability `Dir` or explicit component validation. Three low/info residual
  items were closed:
  - `src/mappings.rs`: the runtime `--mappings-dir` pack reader now caps each
    `*.csv` at `MAX_MAPPING_FILE_BYTES` (16 MiB) with a memory-bounded read
    (never buffers more than the cap + 1 byte), so one hostile/huge pack file
    can no longer OOM the importer at startup.
  - `src/yara_fetch.rs`: the ZIP size-cap pre-scan now requires a **single**
    structurally-valid end-of-central-directory record (`sole_eocd`), closing a
    dual-EOCD parser-confusion gap where a crafted second EOCD could steer
    `lo_zip`'s extractor to a different central directory than the pre-scan
    validated, defeating the pre-allocation guard.
  - `src/yara_fetch.rs`: `commit_files` / `write_provenance` now write and
    rename rule files through a cap-std `Dir` handle instead of `std::path`
    joins, so containment to the rules directory is OS-enforced beneath the
    `safe_rule_basename` sanitiser.
  Each fix ships a regression test
  (`with_dir_capped_skips_files_over_the_byte_cap`,
  `pre_scan_rejects_a_dual_eocd_archive`,
  `commit_files_contains_a_separator_in_the_base_name`); all authored to fail
  before the fix and pass after.

### Changed

- Version bump to 1.6.32.
- **`scripts/release_pipeline.sh`: fixed a step-5/5b hang.** Step 5 (viewer
  restart) captured the daemon-launching `start_viewer` in a command
  substitution (`new_pids="$(start_viewer)"`); `$(...)` blocks until its stdout
  pipe reaches EOF, which the long-running viewer kept open, hanging the whole
  pipeline. The launch is now a pure side effect (subshell stdout to
  `/dev/null`) and the caller queries `find_viewer_pids` separately. Step 5b
  (headless-Chrome screenshots) is now wrapped in a bounded `timeout`
  (`RP_SCREENSHOT_TIMEOUT`, default 300 s) so a slow or stuck render is killed
  (rc=124) and recorded as a step failure instead of blocking the run. The
  three bash gates (`bash -n`, `shfmt`, `shellcheck -o all`) stay clean.
- **rust-doctor: genuinely cleared all 12 warnings (no suppressions; score
  stays 100/100).** The workspace-health gate (`rust-doctor.toml`
  `fail_on = "warning"`) now exits 0, fixed by code changes rather than
  `rust-doctor-disable` annotations:
  - High cyclomatic complexity: `cli::string_option_target` became a
    flag→field-selector data table (`fn` pointers); the five i18n `tr_*`
    translation tables became `const` `(key, en, de, fr)` data tables behind a
    shared `from_table` lookup (`tr_export2` further split into
    `_fields`/`_actions` to stay under the line-length limit). Expressing the
    tables as data drops their measured complexity to ~1.
  - `collect-then-iterate`: the test `argv` helper returns `impl Iterator`
    instead of `.collect::<Vec<_>>().into_iter()`.
  - `excessive-clone` (in-loop): `build_artifacts` now builds the ZIP first
    then moves artifacts out of `rendered` (no clone); `write_artifacts` takes
    `Vec<Artifact>` by value and moves each filename into the result (and into
    the error on the diverging path).
  - `dbdump::restore_dump`/`main` keep their earlier `decode_and_write` /
    `run_early_action` extractions.
  The 136 `info`-level `excessive-clone` findings are heuristic and
  non-gate-failing (clippy's `redundant_clone` confirms they are necessary);
  they are left in place. `cargo clippy -D warnings`, `cargo +nightly fmt` and
  the affected unit tests stay clean.
- **Encryption-at-rest dependency currency.** Bumped `chacha20poly1305`
  0.10.1 → 0.11.0 (the RustCrypto `aead` 0.6 / `cipher` 0.5 cohort) and migrated
  `src/crypto.rs` to the new API: `XNonce::generate()` for the random 24-byte
  nonce (replacing the removed `AeadCore::generate_nonce(&mut OsRng)`), and
  `Key`/`XNonce` via `From`/`TryFrom` instead of the deprecated `from_slice`.
  No behaviour change — the round-trip **and** AEAD tamper-detection tests pass
  unchanged; `cargo deny` (advisories/bans/licenses/sources) and clippy stay
  clean. 0.10.1 carried no RUSTSEC advisory, so this is supply-chain currency,
  not a vulnerability fix. Tracked as CSAF ndaal-sa-2026-150 (CVSS 0.0/NONE).
- **Hashing dependency currency (RustCrypto digest 0.11 cohort).** Bumped `sha2`
  0.10 → 0.11 and `sha3` 0.10 → 0.11 — deliberately **not** sha3 0.12, which
  dropped the SHAKE/XOF support the checksum sidecars need; sha3 0.11 keeps
  `Shake256` on the digest 0.11 API. `src/sidecar.rs` (the SHA-256/512,
  SHA3-512 and SHAKE256-512 sidecars) compiles unchanged on the new `Digest` /
  XOF API and the 9 sidecar unit tests pass with byte-identical digests. This
  unifies the whole RustCrypto stack on `digest` 0.11 / `crypto-common` 0.2,
  resolving the transient `crypto-common` 0.1/0.2 duplicate the
  chacha20poly1305 bump introduced. No RUSTSEC advisory on the old versions —
  supply-chain currency. Tracked as CSAF ndaal-sa-2026-151 (CVSS 0.0/NONE).

### Tests

- **Fixed a char-boundary panic in the `fuzz_lang_resolve` harness.** The
  harness split a `from_utf8_lossy` string at `len/2`, a raw byte index that can
  land inside the multi-byte U+FFFD replacement char and panic on slicing — a
  bug in the *fuzz scaffold*, not in `i18n::resolve` (which is correct and never
  panics). The midpoint now advances to the next `is_char_boundary`. Surfaced by
  the release pipeline's fuzz-smoke step.
- **Closed the last untrusted-token parser gap.** Added a proptest invariant
  and the `fuzz_store_tokens` libFuzzer target for the three store/URL token
  parsers `store::{TargetKind, Modal, Status}` (`catalog`/`practice`/`control`,
  `MUSS`/`SOLLTE`/`KANN`, `draft`/`release`) — the only `from_token` parsers not
  already under proptest + fuzz. Invariants: never panic on arbitrary input, an
  unknown token maps to `None`, the three token sets are disjoint (any input
  matches at most one), and every variant round-trips through `as_str()`.
  proptest invariants 67 → 68; fuzz targets 39 → 40.

## [1.6.31] - 2026-06-29

### Fixed

- **Release-gate hygiene.** Cleared two QA-gate failures the release verify
  surfaced: a `rustdoc::private_intra_doc_links` error in each of `src/demo.rs`
  (`badge_svg` → private `demo_badge_svg`) and `src/scan.rs` (`update_clamav_db`
  → private `SCAN_TIMEOUT`), now plain code spans instead of intra-doc links so
  `cargo doc -D warnings` is clean; and a `cargo-machete` false positive on the
  `kani-harness` crate (its `serde` / `serde_json` / `cap-std` deps are used
  only through `#[path]`-included modules) via a `[package.metadata.cargo-machete]
  ignored` entry.

### Tests

- **Closed the last two property/fuzz coverage gaps.** Added a proptest
  invariant **and** a `fuzz_truncate_chars` target for
  `search::truncate_chars` (the char-boundary excerpt truncator — previously
  only a single unit test), and a proptest invariant **and** a
  `fuzz_frameworks_parse` target for `frameworks::parse` (the framework-toggle
  CSV parser, run once at start-up via a `OnceLock` over the human-edited
  `data/frameworks.csv`, so a malformed edit must degrade gracefully rather
  than panic the process). `frameworks::parse` is now `pub` (mirroring
  `mappings::split_csv_line`) so the fuzz target can reach it. proptest
  invariants 31 → 33; fuzz targets 37 → 39.
- **cargo-mutants finalize (`filter` / `scan` / `cli`).** Ran the mutation gate
  over the three parsing-heavy modules (132 mutants: 55 caught, 69 unviable, 8
  survivors; `filter.rs` was already mutation-clean). Killed the three real
  survivors with new unit tests: the `cli::parse_args` value-flag test now also
  exercises `--export` (it had been omitted), `scan::config` is shown to
  round-trip the value installed by `set_config`, and `scan`'s `TempGuard` is
  shown to delete its temp file on drop. The remaining survivors are documented
  in `scan.rs` as accepted: the subprocess result-handling in `run_capturing` /
  `run_clamscan` / `run_yara` / `update_clamav_db` (they exec real `clamscan` /
  `yr` / `freshclam` binaries, so no in-process unit test can kill them) and the
  `#[cfg(not(unix))]` `write_private` (dead code on a unix host).
- **Audit-driven negative / adversarial tests** (from a grounded test-gap
  audit). AEAD tamper-detection for `crypto::open_text`/`open_blob`: a flipped
  ciphertext or Poly1305-tag bit, and degenerate marked lengths, must return
  the stored value verbatim (fail-safe) and never the tampered plaintext — the
  integrity promise round-trip tests cannot prove. `dbdump::restore_dump`
  corrupt-source rejection: a bad SQLite header leaves no destination behind, a
  corrupt gzip / broken SQL / unrecognised extension / pre-existing destination
  all yield a typed `DumpError` with no panic and no clobber. `same_origin`
  edge cases: IPv6-bracketed authorities, differing ports, case-insensitive
  header-name matching, mis-cased/empty `Sec-Fetch-Site`, and the `http`-crate
  CR/LF/NUL header-injection guard. (The TLS-1.2-downgrade rejection test was
  assessed infeasible in-process — the rustls build omits the `tls12` feature
  entirely — and stays covered by the openssl/testssl gate scripts.)
- **Concurrent annotation-write contention test.** 16 threads call
  `store::save` on the same target through a shared `Arc<Store>`; the
  `Mutex<Connection>` must serialize them into distinct, gap-free revisions
  `1..=N` with every body persisted intact. Guards the append-only invariant —
  the primary untrusted write path — against a future drop-and-relock refactor
  of `save` that the existing sequential tests could not catch.
- **Meilisearch graceful-degradation tests** (`tests/test_meili_fallback.rs`).
  A `MeiliClient` pointed at an unreachable port, a non-HTTP garbage responder,
  or an HTTP 200 with an unparseable body must fail cleanly — `health()` false
  and `search()` returning `Err`, never a panic or hang — which is precisely
  what lets every call site fall back to the always-available built-in engine.
  Plain-TCP loopback mocks (`http://127.0.0.1:…`) keep the tests TLS-free.
- **Export-format snapshot tests** (`insta`). A deterministic synthetic catalog
  is snapshotted as control-scoped JSON and whole-catalog Markdown, pinning the
  user-facing export contract so silent drift in JSON shape or Markdown
  heading/field layout fails review instead of shipping unnoticed. Adds `insta`
  as a dev-dependency and the reviewed snapshots under `src/snapshots/`;
  regenerate with `cargo insta review` after an intended format change.

### Verification

- **Standalone `kani-harness/` crate makes `cargo kani` usable.** The full
  crate cannot be model-checked: `cargo kani` compiles `libsqlite3-sys`, whose
  build script uses the unstable `cfg_select` feature that Kani's pinned
  toolchain rejects (`error[E0658]`, reproduced against Kani 0.67.0). The new
  crate `#[path]`-includes only the rusqlite-free pure modules and proves three
  bounded properties — `filter::paginate` yields a well-formed window with no
  arithmetic overflow across the **entire** `usize × usize` input space (the
  exhaustive guarantee proptest can only sample), the empty-catalog corner, and
  `cli::parse_args` defaulting. All three report `VERIFICATION:- SUCCESSFUL`
  (~4 min). String-processing functions stay with proptest + fuzz (CBMC cannot
  tractably model their symbolic `String`/UTF-8 state space). The crate is its
  own workspace, excluded from the published binary (`kani-harness/target` and
  its `Cargo.lock` are git-ignored).
- **Wired the `test_cargo_kani` quality gate to the harness crate.**
  `tests/scripts/test_cargo_kani.sh` now runs `cargo kani` inside
  `kani-harness/` (instead of the un-checkable `cargo kani --workspace`),
  counts harnesses from `kani-harness/src`, and self-skips when the crate is
  absent. Fixed two latent bugs the now-reachable result path exposed: SC2312
  masked command substitutions and a `grep -c … || echo 0` double-zero (`0\n0`)
  that crashed the verdict arithmetic. shellcheck `-o all` + shfmt clean; the
  bats contract passes 36/36; a live run reports 3/3 harnesses
  `VERIFICATION:- SUCCESSFUL`.

### Documentation

- **arc42 architecture documentation** under `documentation/arc42/` — the
  canonical 12-section arc42 layout (`en/01-introduction-and-goals.md` …
  `en/12-glossary.md`) plus an index, authored per `skills/documentation` and
  cross-checked against the source. Includes Mermaid context, building-block,
  runtime and deployment diagrams. markdownlint + rumdl clean at 80 columns
  (tables and code blocks exempt per `.markdownlint.json`).
- **Office-format mirrors of the arc42 docs** — a `.docx` and an `.odt` for
  each of the 12 sections plus the index, generated with Pandoc per
  `skills/documentation`. They are not shipped in the published crate (the
  `Cargo.toml` `include` allowlist carries only the two guide markdowns).

### Tooling

- **Goose load-test harness** in a standalone `loadtest/` crate — its own
  workspace, like `kani-harness/`, so goose's `reqwest` HTTP-client tree never
  enters the published crate or its deny-lints / cargo-deny. A single
  `BrowseAllIds` scenario covers **every read-only GET endpoint** AND **every
  real catalog id**: the complete id lists are embedded from
  `data/control_ids.txt` (all 983 control ids) and `data/practice_ids.txt` (all
  20 practice domains, generated from the catalog), and the id transactions
  (`/control/{id}`, `/control/{id}/raw.json`, `/practice/{id}`) loop their full
  list once per run, so `--users U --iterations N` hits every id `U × N` times.
  The parameter-free pages plus `/search` (+ htmx partial), `/lang/{code}` and
  `/static/{*path}` are hit `U × N` times total. Mutating `POST` routes and
  seeded-data GETs (`/annotations/asset/{id}`, `/annotations/template/{name}`)
  are excluded — the load test never changes server state. Id routes use
  `get_named` to aggregate metrics per `…/{id}` label. Built against goose's
  `rustls-tls` — the default native-tls backend cannot complete the viewer's
  TLS-1.3-only self-signed handshake even with `--accept-invalid-certs`.
  Verified end-to-end against a live viewer with **0 failures** (every-endpoint
  pass at ~346 req/s); `--users 20 --iterations 50` drives the full
  ~2.0M-request, 1000-hits-per-id stress run. `loadtest/target` and
  `loadtest/Cargo.lock` are git-ignored.

## [1.5.30] - 2026-06-27

### Changed

- **Refreshed semver-compatible dependencies** via `cargo update` (no manifest
  or source change): `quote` 1.0.45→1.0.46, `syn` 2.0.117→2.0.118, `cc`
  1.2.64→1.2.65, `log` 0.4.32→0.4.33, `uuid` 1.23.3→1.23.4, `arrayvec`
  0.7.6→0.7.7, and the `wasm-bindgen` family + `js-sys` 0.3.102→0.3.103
  (0.2.125→0.2.126). The `sha2`/`sha3` 0.11/0.12 majors were intentionally
  held (they require the RustCrypto `digest` 0.11 API migration). `cargo
  build`, `clippy -D warnings`, the full test suite, `cargo deny check`, and
  `cargo audit` all pass unchanged on the refreshed lockfile.

### Security

- **CSP `img-src` tightened to drop `data:`** (`src/router.rs`), so stored
  annotation Markdown can no longer embed inline `data:` images (markdown-rs
  strips dangerous link protocols but not `![](data:…)`). Defense-in-depth — not
  active XSS, as `data:` images are inert. (`ndaal-sa-2026-142`, CVSS 3.1 2.6 /
  v4.0 2.1, Low.)
- **`--restore-dump` decompression + source-size caps** (`src/dbdump.rs`): the
  source read and the `.sql.gz` gunzip are now bounded at 512 MiB each, so a
  decompression-bomb dump cannot OOM the one-shot restore process.
  (`ndaal-sa-2026-143`, CVSS 3.1 3.3 / v4.0 2.0, Low.)
- **Meilisearch response body bounded** with `http_body_util::Limited` at 8 MiB
  (`src/meili.rs`), guarding against memory exhaustion from a misbehaving
  operator-trusted backend; `search()` falls back to the built-in engine on a
  tripped cap. (`ndaal-sa-2026-144`, CVSS 3.1 2.2 / v4.0 2.1, Low.)
- **Encryption key-material intermediates zeroized** (`src/crypto.rs`, new
  direct `zeroize` dep): `load_key_file` wipes the raw file buffer and returns
  `Zeroizing<[u8; 32]>` so key copies clear on drop. Defense-in-depth only.
  (`ndaal-sa-2026-145`, CVSS 0.0 / NONE.)
- **File-count cap on the YARA-archive extractor** (`src/yara_fetch.rs`):
  `MAX_RULE_FILES` (10,000) rejects an archive declaring an enormous entry
  count during the central-directory pre-scan (a file-count bomb), complementing
  the existing per-file/total size caps. Bounded already by the 64 MiB download
  cap and SHA-256-pinned, so defense-in-depth. (`ndaal-sa-2026-147`; CVSS 3.1
  3.7 Low / v4.0 6.3 Medium — the two systems diverge on this bounded,
  best-effort, startup-only case.)
- Documented each dependency refresh above as a CSAF 2.1 advisory,
  `ndaal-sa-2026-135` through `-141` (CVSS 0.0 / NONE — supply-chain freshness
  records, not vulnerabilities; no RustSec advisory is associated with any
  version).

### Tests

- Added the `fuzz_restore_dump` and `fuzz_dump_file_in` cargo-fuzz targets and
  two `dump_file_in` unit tests, closing the remaining fuzz/unit coverage gaps
  on the dump restore-decode and download-filename-containment paths (assurance
  only; `ndaal-sa-2026-146`, CVSS 0.0 / NONE).
- **Reused the `safe_unzip` threat-test taxonomy** as deterministic unit tests
  against our own extraction/decompression code (we use `lo_zip` + `flate2`):
  file-count / per-file / total decompression-cap boundaries on the YARA
  central-directory pre-scan (via a forged central directory), the zip-slip
  basename corpus on `safe_rule_basename`, the symlink/setuid-inert guarantee on
  `commit_files`, and the dump gzip-cap boundary on the new `gunzip_capped`
  helper. (`ndaal-sa-2026-148`, CVSS 0.0 / NONE.)
- **Closed five `cargo mutants` gaps in the YARA central-directory pre-scan**
  (`ensure_claimed_sizes_within_caps`) found while mutation-testing the new caps:
  exact-boundary acceptance tests pin the file-count, per-file, and total cap
  comparisons against `>=`/`==` mutations (which a strictly-over input can't
  distinguish), and a truncated-record test pins the `cd_err` message. Each kill
  was reproduced by hand. Remaining survivors are equivalent mutants (dead
  defensive `is_empty()`/`==".."` operands in `safe_rule_basename`, unreachable
  because `Path::file_name` already excludes them) or require a live network /
  a real 64 MiB archive to reach (the curl path and the post-inflate checks,
  both already guarded by the tested pre-scan).
- **Closed seven `cargo mutants` gaps in the dump module** (`src/dbdump.rs`):
  an exact-cap boundary test on `gunzip_capped`, a `list_dump_files` fixture
  test pinning the `annotations-*.{db,sql,sql.gz}` selection filter, and a
  `restore_dump` round-trip (`.sql` + `.db` into nested destinations) pinning
  the SQLite-magic check, the source-size guard, and the parent-directory
  create. Each kill was reproduced by hand. Remaining survivors need a 512 MiB
  fixture (the exact source-cap boundary) or assert exact constant values (the
  `*` arithmetic in the size-cap consts) — not worth a brittle test.
- **Closed a further batch of `cargo mutants` gaps from a full lib sweep** of
  `template_import`, `index`, `oscal`, `export`, and `store`: exact-boundary
  acceptance tests (the per-file template-byte cap, the template-file-count cap,
  the store template cap), equality-sensitive fixtures (`CatalogData::practice`
  lookup, sec-level counts, raw-control detection by class/groups), match-arm
  coverage (`TargetKind::from_token`, the search-result URL builder), the
  export rendering paths (`render_control` / `push_control_blocks` section
  guards, the practice-scoped `build_document`/`pdf_blocks`/`json_bytes`
  filters, `write_artifacts` no-clobber), `BulkWrite` rollback-on-drop, and
  `now_iso8601` format pinning. `crypto`'s lone survivor is the documented
  `|`→`^` equivalent (the nibbles never share bits); `store::ping` (a `SELECT 1`
  that always succeeds) and the size-cap const arithmetic are not cheaply
  killable.

### Fixed

- **`cargo-careful` quality gate no longer silently self-skips.** The runner
  `tests/scripts/test_cargo_careful.sh` (carried over from the upstream
  `vulnerability-lookup-rs` gate suite) probed for the tool with `cargo
  +nightly careful --version`, but cargo-careful has no `--version`
  subcommand — the probe always exited non-zero, so the gate reported
  "cargo-careful not on PATH" and skipped even when it was installed, in both
  standalone and `scripts/quality_gates.sh` (gate `cargo_careful`) runs. It
  now probes `command -v cargo-careful`. Also corrected the stale standalone
  `QG_WORKSPACE` default (was `${REPO_ROOT}/vulnerability-lookup-rs`, a
  non-existent nested crate path) and the `REPO_ROOT` depth (`../../..` →
  `../..`) so a direct invocation resolves this single-crate repo's root, and
  refreshed the carried-over project name and doc paths. All 36
  `test_cargo_careful.bats` assertions still pass (incl. `shfmt`,
  `shellcheck -o all`, the canonical-header diff, and the runtime rc=0 check).
- **`cargo-insta` snapshot-drift gate de-staled**
  (`tests/scripts/test_cargo_insta.sh`). It carried the same
  `vulnerability-lookup-rs` `QG_WORKSPACE` default and `../../..` `REPO_ROOT`
  depth as the careful gate, and its pending-snapshot pre-scan looked in a
  `crates/` directory that does not exist in this single-crate repo — it now
  scans the repo root (pruning `target/`). The `cargo insta --version` probe
  was already valid (cargo-insta supports `--version`, unlike cargo-careful),
  so only the path/name corrections were needed. The crate ships no `insta`
  snapshots, so the gate is a clean no-op drift check. All 36
  `test_cargo_insta.bats` assertions pass. The gate is now wired into
  `scripts/quality_gates.sh` (added to `ALL_GATES` in the FAST Rust group, so
  `--fast` runs it).
- **`cargo-miri` UB gate de-staled and made runnable on this crate**
  (`tests/scripts/test_miri.sh`). Besides the same ported staleness
  (`QG_WORKSPACE`, `REPO_ROOT` depth, project name, Usage paths), its
  `MIRI_PACKAGE` defaulted to `vl-core` — the reference project's crate,
  absent here — so `cargo miri test --package vl-core` errored out and the
  gate never ran; it now defaults to `grundschutz-oscal-viewer`. Because Miri
  cannot execute this crate's C FFI (`libsqlite3-sys`, `aws-lc-rs`), a new
  `MIRI_TEST_FILTER` knob scopes the run to the FFI-free pure modules
  (`oscal:: index::`) by default, so the gate yields a meaningful UB check
  instead of aborting on the first foreign call. The default filter also
  `--skip`s `index::tests::embedded_catalog_parses_with_expected_shape`,
  which deserialises the entire ~1000-control embedded catalog and is
  effectively non-terminating under the interpreter; the remaining oscal/index
  tests use small in-memory fixtures and finish in seconds (verified clean:
  6 passed, 0 failed, no UB). All 36 `test_miri.bats` assertions pass.
- **Synced `tests/scripts/` with the upstream vulnerability-lookup-rs gate
  suite (adapted pull).** Pulled upstream's hardening into 7 common runners
  (`.git` exclusions in the bandit/mypy/vulture/pyre linters, a pipefail-safe
  `grep … || true` in `unknown_fields`, a `jaq`/log-spacing fix in the biome
  runner, the time-bounded geiger flag-set), adapting the nested
  `vulnerability-lookup-rs/` layout paths/names to this single-crate root.
  Imported three informational Rust/doc gates — `doc_drift`, `dokono_rs`,
  `rust_meth` — wired into `scripts/quality_gates.sh` as full-sweep (LIVE)
  gates so their heavy tools stay out of `--fast`. Skipped the upstream-only
  SQL/dashboard/scanner gates as inapplicable here, and held `mkdlint` back
  (its upstream rewrite adds a SARIF end-to-end test that does not pass in
  this environment).
- **De-staled the entire `tests/scripts/` gate-runner suite** (357 files —
  144 `.sh` plus their `.bats`/`.README.md` siblings). Every runner carried
  the upstream nested-layout convention, which is broken when a runner is
  invoked **standalone** in this single-crate repo: applied the portable fix
  uniformly — `REPO_ROOT` depth `../../..` → `../..` and the `QG_WORKSPACE`
  default `${REPO_ROOT}/vulnerability-lookup-rs` → `${REPO_ROOT}` — and
  stripped the carried-over `vulnerability-lookup-rs/` path prefixes, renaming
  the project throughout (`nvulnlookup` → `grundschutz-oscal-viewer`,
  `Administrator_Guide.md` → `administrator_guide.md`). All runners stay
  `bash -n`- and `shfmt`-clean with canonical headers and executable bits
  intact; spot-checked `.bats` contracts pass. (Runs via `quality_gates.sh`
  were already correct — it exports `QG_WORKSPACE`; this fixes the standalone
  invocation path.)
- **Imported 5 upstream security/DAST scanner gates** (follow-up adapted sync
  from vulnerability-lookup-rs): `kryptonclaw` (CI/CD security),
  `nyx_scanner` and `yamtam_rt` (source / secret / dependency scanners),
  `nexcore_downloads_scanner`, and `rwalk` (web-surface DAST) — wired into
  `scripts/quality_gates.sh` as full-sweep (LIVE) gates that self-skip when
  their tool is absent. Held back `cert_dump` and `phylax` (their upstream
  end-to-end SARIF bats tests fail in this environment, like `mkdlint`), and
  skipped the inapplicable `dashboard_field_distinctness`, `ramparts` (MCP),
  `sql_with_sqlfluff`, and `sqllogictest_bin` gates.
- **Fixed a silent `REPO_ROOT` mis-resolution across 43 newer gate runners.**
  The newer upstream boilerplate derives `WORKSPACE_ROOT="${SCRIPT_PATH}/../.."`
  (correct = crate root) then `REPO_ROOT="${WORKSPACE_ROOT}/.."` — correct in
  the upstream nested `vulnerability-lookup-rs/` layout, but in this
  single-crate repo it overshoots to the worktree's parent. Scanners then ran
  `git -C "${REPO_ROOT}" ls-files` outside the repo, got an empty list, and
  **silently self-skipped** ("no files to scan") while their `.bats`
  "self-skips OR succeeds" test still passed — so 41 committed gates were
  no-ops and `test_python_with_ruff` even targeted `.claude/worktrees`. The
  prior de-staling sweep only matched the old `${SCRIPT_PATH}/../../..` form;
  this corrects the new-style form to `REPO_ROOT="${WORKSPACE_ROOT}"` and
  removes the redundant readonly-conflicting `REPO_ROOT` override in
  `test_secrets_with_gitleaks.sh`.
- **Fixed and re-enabled the `cert_dump` and `phylax` scanner gates.** Beyond
  the `REPO_ROOT` fix above, `cert_dump` hit a `grep -c … || printf '0'`
  double-count bug (on a zero-match repo, `grep -c` prints `0` *and* exits 1,
  so the fallback appended a second `0` → `"0\n0"` arithmetic error → no
  `SUMMARY.txt`); switched to `|| true` (grep-c already prints the count).
  Both now produce validated SARIF + `SUMMARY.txt` and pass their end-to-end
  `.bats`, so they are wired into `scripts/quality_gates.sh` as LIVE gates
  (runner-slug count 110 → 112). Also synced the missing
  `test_dashboard_entry_titles.README.md`.
- **Formatter QA gates no longer mutate the working tree.** Once the
  `REPO_ROOT` fix made the formatter gates actually target the repo,
  `test_python_with_ruff.sh` ran `ruff format` *in place* — switched to
  `ruff format --check` (report-only), so `quality_gates.sh --fast` never
  rewrites source files. (The biome/shfmt/shellharden gates already write
  their autofix output to the report dir on copies, not the source.)
- **Completed the `test_settings_toggle_combinatorics` gate trio** — added
  its missing `.bats` (from the canonical template) and `.README.md`, and
  tightened its `mkdir`/`cp` flags to `-p -v` / `-f -p -v`. Four template
  tests are documented `skip`s: it is a CDP / headless-Chrome browser test,
  not a scan-and-report gate (no `OUT_DIR` convention), and ships a condensed
  boilerplate body. Runs 35 ok / 0 failures.

## [1.5.29] - 2026-06-25

### Added

- **Offline ClamAV signature-DB fallback.** `--clamav-db-dir <DIR>` /
  `GSV_CLAMAV_DB_DIR` points `clamscan --database` at a bundled signature
  directory, and `--update-clamav-db` / `GSV_UPDATE_CLAMAV_DB` best-effort
  refreshes it via `freshclam` on start-up. Resolution order: explicit dir →
  freshclam-refreshed dir → system ClamAV DB → fail-open (each candidate used
  only when it actually holds a `.cvd`/`.cld`/`.cud` database). The admin
  guide documents it in §2.4 (resolution order) and §10.5 (air-gapped
  walk-through). The packaging script `scripts/update_clamav_db.sh` builds
  the DB as the release artifact
  `clamav-db-<date>.tar.zst` (+ `.sha256`), attached beside the binaries by
  `create_release_on_gitlab.sh` — **never embedded** in the binary (the DB is
  ~300 MB and changes daily, and embedding would break the crates.io 10 MiB
  limit). Covered by `scan.rs` unit tests, a `test_scan.rs` EICAR bundled-DB
  detection (which also pins the `run_clamscan` exit-code decision), three
  proptest invariants, and the `fuzz_clamav_db` fuzz target.

### Changed

- **Dropped the unmaintained `rustls-pemfile` dependency** (RUSTSEC-2025-0134).
  The optional `--tls-cert` / `--tls-key` PEM loaders in `src/tls.rs` now use
  `rustls-pki-types`' own `pem` module (`CertificateDer::pem_slice_iter` /
  `PrivateKeyDer::from_pem_slice`) — the maintained successor, already in the
  tree via `rustls`, so no new dependency is added. The corresponding
  `deny.toml` advisory ignore is removed; `cargo deny check` is clean again.

### Tests

- **Closed two mutation-testing coverage gaps in the encryption-at-rest module**
  (`src/crypto.rs`), found by `cargo mutants`. A direct round-trip unit test now
  pins the `load_key_file` length guards (32 raw bytes / 64 hex characters,
  accept/reject), and a short-blob unit test now pins the `Cipher::open`
  `blob.len() < NONCE_LEN` guard (a too-short marked value is returned verbatim
  rather than panicking) — previously exercised only by the fuzz target. No
  production-code change: the shipped behaviour was already correct; only the
  deterministic regression coverage improved. Documented in CSAF
  `ndaal-sa-2026-133` and `-134` (both CVSS 0.0 / NONE — assurance only).
- **Closed 13 further `cargo mutants` coverage gaps** across `frameworks.rs`
  (CSV comment/header skips), `search.rs` (exact- vs substring-id scoring),
  `sidecar.rs` (`SidecarHashes::any` 5-way OR + every `from_tokens` family arm),
  and `mappings.rs` (`is_empty` + `collect_csv` comment/empty-field skips). Each
  surviving mutant was reproduced and the new test confirmed to catch it; no
  production-code change.
- Added `.cargo/mutants.toml` (with `[profile.mutants]`) pinning the
  mutation-testing setup: `cargo-nextest`, a debug-symbol-free build profile,
  and a 3× CI-timeout multiplier.

## [0.4.27] - 2026-06-23

### Added

- **Health probes for orchestrators.** `GET /healthz` (liveness — always `200
  ok` while serving) and `GET /readyz` (readiness — `200 ready` once the catalog
  is loaded and, when `--db` is set, the store answers a `SELECT 1`; `503`
  otherwise). Plain-text, un-gated, suitable for load-balancer/k8s probes.
- **Operator-supplied TLS certificate.** `--tls-cert <FILE>` / `--tls-key
  <FILE>` (env `GSV_TLS_CERT` / `GSV_TLS_KEY`) present a CA-issued PEM
  certificate chain + key (PKCS#8 / PKCS#1 / SEC1) through the existing TLS 1.3
  config instead of the ephemeral self-signed certificate. Both must be given
  together; covered by tests + a `fuzz_load_cert` fuzz target + proptest.
- **Structured JSON logging.** `--log-format json` (env `GSV_LOG_FORMAT=json`)
  emits newline-delimited JSON on both the console and the `--log-dir` file for
  log aggregation; `text` (human-readable) remains the default.
- **Compress the SQL dump.** A `compress` checkbox on the database-dump form
  gzips the SQL text dump to `annotations-<stamp>.sql.gz` (the binary `.db`
  snapshot is left as-is); the checksum sidecars then cover the `.gz`. Uses
  pure-Rust `flate2`/`miniz_oxide`, so it cross-compiles to every release target.
- **Dump-management UI.** The Export page now lists existing dumps in the
  `--dump-dir` root with per-dump **download** (`GET /export/dumps/download`) and
  **delete** (same-origin `POST /export/dumps/delete`, removes the file + its
  sidecars). Both are confined to the dump root by `dbdump::dump_file_in`
  (single filename only — no `..`, separators, or absolute paths).
- **Restore a database from a dump.** `--restore-dump <FILE>` rebuilds an
  annotation database from a `.db` snapshot or a `.sql` / `.sql.gz` text dump and
  exits. It is offline and **non-destructive**: the target (`--db` or
  `<data-dir>/annotations.db`) must not already exist, so a restore never
  overwrites a live database — point `--db` at the result afterwards.
- **Encryption-at-rest for annotations.** `--encryption-key-file <FILE>` (env
  `GSV_ENCRYPTION_KEY_FILE`) seals annotation **bodies** and image **asset
  bytes** with XChaCha20-Poly1305 (AEAD; a fresh 24-byte random nonce per value)
  before they reach SQLite, and opens them on read — so a stolen `.db` file (or
  any `.db`/`.sql`/`.sql.gz` dump of it) yields ciphertext, not notes. The key is
  32 raw bytes or 64 hex characters. Plaintext and encrypted rows coexist via a
  self-describing marker, so the key can be added to an existing database with
  **no migration**; a configured-but-unreadable key is a hard start-up error
  rather than a silent plaintext fallback, and full-text annotation search still
  works (the encrypted path decrypts and matches in memory). Pure-Rust
  RustCrypto, so it cross-compiles to every target. Covered by store
  round-trip / on-disk-ciphertext / wrong-key / coexistence tests, `crypto` unit
  tests, proptests, and a new `fuzz_crypto_open` target. **Back up the key file
  and restrict its access — losing it loses the encrypted data.**
- **Annotations are searchable.** When `--db` is enabled, the search page surfaces
  a *Matching annotations* section (case-insensitive substring over the latest
  annotation bodies) alongside the catalog hits, linking to each annotated target.
- **Trilingual UI (English / German / French).** The interface chrome —
  navigation, footer, settings, headings, table headers, filters, buttons and
  form labels — is now translated. A **language switcher** in the navbar pins
  the choice in a `gsv_lang` cookie (`Secure`/`HttpOnly`/`SameSite=Lax`); absent
  the cookie the browser's `Accept-Language` header is honoured, with English as
  the fallback. The switcher is a plain `GET /lang/{code}` link (no inline JS, so
  the CSP script hashes are unchanged) that returns to the referring page by
  path only — no open-redirect. The embedded BSI catalog content and BSI terms
  of art (`MUSS`/`SOLLTE`/`KANN`, `erhöht`) intentionally stay in their source
  language, as does the German legal *Impressum* and the long-form embedded
  documents (README, guides). Covered by `i18n` unit tests, route tests
  (cookie precedence, switcher redirect, German/French rendering), proptests and
  a `fuzz_lang_resolve` target.
- **Accessibility.** A *Skip to main content* link is now the first focusable
  element (jumping past the navbar to a focusable `<main id="main-content">`
  landmark), and the HTMX live-search results are an `aria-live="polite"` region
  so screen readers announce updated results.

### Security

- **Database-dump directory is confined to the `--dump-dir` root** (advisory
  #123). The `/export/db-dump` `dir` field is now an optional relative sub-path
  resolved strictly under the configured dump root by `dbdump::resolve_dump_dir`
  (normal components only; absolute paths, drive prefixes and `..` are rejected),
  so a request can no longer steer the snapshot/SQL/sidecars to an arbitrary
  directory. Covered by unit + integration + proptest + a new
  `fuzz_resolve_dump_dir` target.
- **Heavy export / download / dump work runs off the async executor, bounded**
  (advisories #124, #125). The dump (VACUUM + serialize) and the catalog
  export/download now run on the blocking pool via `spawn_blocking` behind a
  process-wide concurrency semaphore (default 2 permits, 503 when exhausted), so
  a dump no longer pins a Tokio worker or stacks, and a request burst cannot
  saturate the worker pool.
- **`GET /export/download` is now same-origin gated** (advisory #126), matching
  the mutating POST endpoints — a cross-origin page can no longer trigger a
  resource-intensive catalog build (`Sec-Fetch-Site: none` direct navigations
  still work).
- **Dump artifacts are written no-clobber** (advisory #127). The `.sql` and every
  checksum sidecar use cap-std `create_new`, so a same-name file is an error
  rather than a silent overwrite — matching the catalog export's contract.
- **Supply-chain gates hardened** (advisories #128, #129). `cargo-deny` and
  `cargo-audit` are now REQUIRED in `run_qa.sh` and run before the crates.io
  publish (escape hatches: `RUN_QA_ALLOW_MISSING_SECURITY_TOOLS` /
  `GSV_ALLOW_MISSING_SECURITY_TOOLS`), and `deny.toml [bans].deny` proactively
  bans the architecturally-forbidden crates (openssl-sys, native-tls, a second
  TLS stack, yara-x/yara, clamav-client).
- **Release scripts clamp resource limits (defense-in-depth).** The canonical
  ndaal `set_resource_limits()` helper (lifted from the `checkin_git.sh` family)
  is now shipped as a sourceable library at `skills/bash/scripts/resource-limits.sh`
  and the `skills/bash` SKILL is corrected to point at it; both
  `release/create_release_on_*.sh` source it and clamp `ulimit` resources before
  any side-effecting work — **core dumps are forced off** (so a crates.io token
  or signing key can never leak into an on-disk crash dump) and file size / CPU
  time / process count / open files / stack are bounded. The crates.io script
  relaxes the memory / file-size / stack caps (`NDAAL_RLIMIT_*=unlimited`) so its
  embedded `cargo publish` verify build is never constrained; every limit is
  overridable via `NDAAL_RLIMIT_*`.

### Fixed

- **Release scripts self-upgrade to a modern Bash.** Both
  `release/create_release_on_*.sh` use Bash 4+ builtins (`mapfile`) but
  previously lacked a version guard, so invoking them under macOS's default
  `/bin/bash` 3.2 aborted with `mapfile: command not found`. Both now carry the
  same `bash >= 4.4` re-exec guard as `scripts/release_pipeline.sh` and
  transparently re-exec under `/opt/homebrew/bin/bash` or `/usr/local/bin/bash`.

### Security advisories

- CSAF 2.1 advisories `ndaal-sa-2026-123` … `-129`, one per finding from the
  0.4.27 security review, each marking 0.3.26 known-affected and 0.4.27 fixed:

  #123 dump-dir traversal (CVSS 6.2), #124 synchronous-dump DoS (4.0), #125
  unbounded executor work (5.3), #126 download missing same-origin (4.3), #127
  dump clobber (2.9), #128 advisory-gate self-skip (0.0), #129 empty
  `deny.toml` ban list (0.0). Scores reflect the loopback-default + same-origin
  bounding.

## [0.3.26] - 2026-06-22

### Added

- **`ndaal/` working-directory layout + three directory flags.** Opt-in start-up
  flags give the viewer a self-contained working tree, defaulting under
  `./ndaal/` (relative to the current directory); see administrator guide §2.7.
  - `--data-dir <DIR>` (env `GSV_DATA_DIR`, default `./ndaal/data`) — base
    directory for the annotation database (`<data-dir>/annotations.db`); `--db`
    still overrides the full path.
  - `--log-dir <DIR>` (env `GSV_LOG_DIR`, default `./ndaal/log`) — log output is
    written to a single append-only file `grundschutz-oscal-viewer.log` here **in
    addition to** the console (best-effort: console-only if the file cannot be
    opened). Writes are mutex-serialised so concurrent log events never
    interleave.
  - `--dump-dir <DIR>` (env `GSV_DUMP_DIR`, default `./ndaal/dumps`) — default
    target for database dumps (see below), each named with a filesystem-safe
    ISO-8601 UTC timestamp.

  Path resolution lives in `src/paths.rs` and carries unit tests, a `proptest`
  invariant and the `fuzz_paths` fuzz target.
- **Database dump (Export menu).** A new **Export DB dump with sidecars** button
  on the `/export` page writes, for the annotation store, both a consistent
  SQLite snapshot (`VACUUM INTO …/annotations-<ISO8601>.db`) and a portable SQL
  text dump (`annotations-<ISO8601>.sql` — schema DDL + `INSERT` rows, text
  single-quote escaped, BLOBs as `X'..'` hex literals), each accompanied by
  **selectable** checksum sidecars (`.sha-256`/`.sha-512`/`.sha3-512`/
  `.blake3-512`/`.shake256-512`, all five enabled by default). The target
  directory defaults to `--dump-dir` and is choosable per request. The trigger is
  a same-origin `POST`; the SQL dump and sidecars are written through a
  capability-scoped `cap-std` handle, and the snapshot is produced by SQLite's
  `VACUUM INTO`. New library surface: `dbdump::run_dump`, `Store::vacuum_into`,
  `Store::sql_dump`, `Store::now_iso8601`. Covered by the
  `tests/test_dbdump.rs` integration sequence (snapshot validity, SQL round-trip,
  selected-sidecar matrix, the route, the no-`--db` path), `proptest` invariants
  (dump file naming; `SidecarHashes::from_tokens` selection) and the
  `fuzz_dbdump_name` + `fuzz_sidecar_selection` fuzz targets.

### Changed

- **Default annotation-database location.** With no `--db`/`GSV_DB`, the database
  is now `<data-dir>/annotations.db` (i.e. `./ndaal/data/annotations.db` by
  default) instead of `<home>/grundschutz-oscal-viewer/annotations.db`. Pass an
  explicit `--db <path>` to keep a fixed location; an empty value still disables
  annotations.
- **`--allow-non-loopback` is now documented in `--help`.** The flag was real and
  working but missing from the usage text (it only appeared in the administrator
  guide). The loopback-bind decision was also lifted into the library
  (`net_bind::is_loopback_bind`) so it carries a `proptest` invariant and the
  `fuzz_loopback_bind` fuzz target, not just `main.rs` unit tests.
- **CLI parser moved to the library.** `Args` + `parse_args` now live in
  `src/cli.rs`, so the parser covering **every** start-up flag is exercised by
  unit tests, a `proptest` invariant (never panics; value flags round-trip) and
  the `fuzz_cli_parse` fuzz target — not only the binary's own tests.

### Security advisories

- **`--allow-non-loopback` posture.** Added `ndaal-sa-2026-120`, a CSAF 2.1
  advisory (dual CVSS 0.0 / NONE) documenting that binding a non-loopback address
  turns the unauthenticated viewer into a network service that must sit behind an
  authenticating proxy.
- **0.3.26 release announcement.** Added `ndaal-sa-2026-121`, a CSAF 2.1
  release-announcement / roll-up for 0.3.26 (dual CVSS 0.0 / NONE; features +
  hardening, no new vulnerability), text drawn from this CHANGELOG section. The
  distribution index was regenerated (`csaf-validator -T basic` clean).

## [0.2.25] - 2026-06-22

### Added

- **Demo seeding (`--seed-demo`).** A new opt-in start-up flag (env:
  `GSV_SEED_DEMO`) that populates the annotation store with demonstration data so
  the Annotations feature is immediately explorable. It runs the per-control
  annotation sequence across the whole catalog — the catalog overview, every
  practice and every control (~1000 targets) — writing a short version history
  (Draft → Release → Draft) and one generated, inert SVG demo badge per target.
  The badge is produced in-process (no extra data files ship in the binary) and
  validated through the same `validate_upload` allow-list as a real upload, so
  the demo corpus is policy-clean by construction. Seeding is **non-destructive**:
  any target that already has an annotation is left untouched, so the flag is safe
  to leave enabled and never clobbers real notes. Requires `--db`; runs once
  before the listener binds, in a single atomic transaction. Covered by an
  exhaustive integration sequence (`tests/test_seed_demo.rs` — full-catalog
  counts, per-target Draft→Release→Draft history, asset round-trip validation,
  non-destructiveness, idempotency and router rendering), `proptest` invariants
  (the generated badge always passes `validate_upload` for any label;
  `sanitize_label`/`md_escape`/`excerpt` safety bounds), a `fuzz_seed_demo`
  fuzz target, and a Bruno HTTP scenario (`test/bruno/seed-demo/` with
  `run-seed-demo.sh`, which spins up a throwaway seeded server) asserting the
  demo data renders at catalog, practice and control level.

### Changed

- **Dependencies.** Updated `bytes` 1.11.1 → 1.12.0 (semver-compatible). `sha2`
  and `sha3` remain pinned to the 0.10 series: `sha3` 0.12 dropped the
  SHAKE/XOF API that the `.shake256-512` export checksum sidecar depends on, and
  0.10 keeps SHA3-512 and SHAKE256 on a single shared `digest` version
  (documented in `Cargo.toml`).

### Security

- **Binary hardening (`.cargo/config.toml` + bincheck).** Added per-target
  linker hardening for release builds and documented the verified posture
  (`scripts/test_bincheck.sh`, run in the release pipeline; reports under
  `documentation/security/binary/bincheck/`).
  - **Linux (musl, x86_64 + aarch64):** full RELRO (`relro` + `BIND_NOW`) and a
    non-executable stack (`-z noexecstack`); `NX` enabled; no `RPATH`/`RUNPATH`;
    symbols stripped; no debug info; no SUID/SGID. The fully-static binaries
    stay `ET_EXEC` (non-PIE) — a deliberate trade-off of the single-static-binary
    promise: a static ELF has no dynamic loader to relocate a PIE image, so
    making them PIE would mean shipping dynamically-linked binaries.
  - **Windows (gnu/gnullvm, x86_64 + aarch64):** ASLR + High-Entropy ASLR and
    DEP/NX (linker defaults). Control Flow Guard is **not** enabled: `-C
    control-flow-guard=yes` is an MSVC-toolchain feature and fails to link
    against the mingw-w64 GNU runtime via zig's lld-link (`undefined symbol:
    __guard_dispatch_icall_dummy`), so it is out of scope for the GNU build —
    as is Authenticode code-signing (binaries are unsigned; verify via the
    published SHA256SUMS). Both would require a windows-msvc toolchain build.
  - **macOS (x86_64 + aarch64):** PIE / ASLR / NX are the Mach-O linker defaults;
    RELRO and `noexecstack` are ELF-only and therefore not applicable.
  - **By design, not findings:** Rust binaries carry no C-style stack canary or
    `FORTIFY_SOURCE` (memory safety is enforced at compile time, with
    `forbid(unsafe_code)` across the crate); `SafeSEH` is N/A on 64-bit.
  - Flags are scoped per target triple, so the host build and the test suite are
    unaffected.

### Security advisories

- **0.1.23 release announcement.** Added `ndaal-sa-2026-119`, a CSAF 2.1
  release-announcement / hardening roll-up for the 0.1.23 release, scored the
  canonical CVSS 0.0 / NONE (no new vulnerability; the per-finding residual
  risks are in `ndaal-sa-2026-108 … 117`). The distribution index was
  regenerated (117 advisories, `csaf-validator -T basic` clean).

## [0.1.23] - 2026-06-21

### Added

- **In-app documentation pages.** The Info menu now links **README**
  (`/readme`), **Administrator Guide** (`/administrator`) and **User Guide**
  (`/user`) alongside the existing Changelog. Each renders the embedded
  Markdown — the exact copy shipped in the binary, via `include_str!` — as
  escaped, scrollable preformatted text, so the documentation always matches
  the running version and is available offline. Covered by mirror tests of the
  Changelog suite, bruno requests, a `proptest` content-escaping invariant and
  the `fuzz_doc_render` fuzz target.

### Security

A hardening pass from an internal audit of the network, header and input paths.
The threat model is unchanged — the viewer binds loopback by default, has no
authentication and is meant for a single local operator — so these defend the
"what if it is exposed" case rather than fixing a reachable break.

- **Loopback bind guard.** The server now refuses to bind a non-loopback
  address unless the operator explicitly opts in with `--allow-non-loopback`
  (or `GSV_ALLOW_NON_LOOPBACK`), which logs a clear warning. A typo'd
  `--bind 0.0.0.0:8443` no longer silently exposes the viewer to the network.
- **Honest malware-scan logging.** When `clamscan` and/or the YARA `yr` binary
  is missing, startup now logs at **warn** that uploads will be accepted
  *unscanned* (instead of implying a scan still happens). Uploads remain
  possible — the operator is told plainly what protection is and is not active.
- **Markdown rendering moved off the async reactor and bounded.** The preview
  endpoint renders inside `spawn_blocking` and rejects input over 256 KiB before
  rendering. The same 256 KiB cap is enforced at the single write choke point
  (`annotations_save`), so stored Markdown can never exceed it and the
  synchronous render on the annotations workspace and the control-detail card is
  bounded too — a large paste can no longer be stored once and replayed to pin a
  worker on every later page view. (`same_origin()` permits non-browser clients
  by design, so it is a CSRF control only — these caps are the DoS backstop.)
- **Request timeouts and HTTP/2 budgets.** Each request is bounded by an overall
  120 s timeout, and HTTP/2 connections are served with explicit
  `max_concurrent_streams` (128) and `max_header_list_size` (64 KiB) limits and
  a timer, narrowing the slow-loris / stream-flood surface beyond the existing
  connection semaphore.
- **Export trigger is now a same-origin POST that creates new files.**
  `/export/run` changed from `GET` to `POST` and is gated on `same_origin`, so a
  third-party page can no longer drive a server-side write by navigation. It now
  defaults to **create-new** (an existing artifact is replaced only when the
  operator ticks the overwrite box, which sends `overwrite=on`).
  `GET /export/download` still streams artifacts without writing. (Filesystem
  traversal was already fully mitigated by `cap-std` plus server-derived,
  single-segment artifact names.)
- **URL / Origin validation.** External mapping URLs and the CSRF `Origin`
  header are validated before use (HTTPS scheme, an authority with no userinfo,
  a non-empty host), so only clean `https://host[:port]` values are emitted or
  trusted.
- **Response headers.** Dropped `preload` (and `includeSubDomains`) from the
  HSTS header — a self-signed single-host viewer must never be submitted to the
  browser preload list — and tightened the Content-Security-Policy `style-src`
  to plain `'self'` by externalising the last inline `<style>` block to
  `/static/css/app.css`. No template emits an inline `<style>` block or a
  `style=` attribute, so the policy carries no `'unsafe-inline'` in any
  directive.

### Security advisories

- **CSAF 2.1 feed conformance.** The repository's ndaal CSAF advisory feed under
  `csaf/2026/` now validates **101/101** against `csaf-validator -T basic`.
  Vendor branding moved from the schema-invalid `document.publisher.x_*` keys
  (the CSAF 2.1 `publisher` object is `additionalProperties: false`) to the
  conformant `document.x_extensions` `dashboard-branding` element across the
  corpus; advisory `ndaal-sa-2026-073`'s CVSS v4 base score was corrected to
  match its vector (4.8 / Medium) with a per-vulnerability notes element added;
  `csaf/provider-metadata.json`'s invalid `publisher.x_*` keys were removed; and
  every advisory now carries a complete, verifying **five-family** checksum
  sidecar set (SHA-256, SHA-512, SHA3-512, SHAKE256-512, BLAKE3-512).
  `enrich_publisher_metadata.sh` was fixed to emit the conformant extension and
  regenerate all five sidecar families. Release-announcement advisories
  `ndaal-sa-2026-103` (0.1.21) and `ndaal-sa-2026-104` (0.1.22) were added. No
  change to the viewer binary.
- **Per-finding advisories + full dual-CVSS feed.** Each 0.1.23 hardening
  finding (§2.1–2.10 above) is now published as its own CSAF 2.1
  `csaf_security_advisory` — `ndaal-sa-2026-108` … `117` — with a CWE and a dual
  **CVSS v3.1 + v4.0** score computed with the `cvss` library. The whole feed
  was brought to complete dual scoring: every previously-unscored vulnerability
  gained a 0.0 / NONE metric and every CVSS-3.1-only one a v4 vector derived
  from its v3, all fully expanded so each metric named in the vector string is
  present (CSAF §6.1.10). Five sibling ndaal advisories were adopted and the
  directory-discovery index (`index.txt` / `changes.csv`) regenerated, so the
  corpus now validates **116/116** against `csaf-validator -T basic` with all
  five sidecar families tri-tool verified (`openssl` + `rhash` +
  `shasum`/`sha3sum`). New repeatable helper scripts — `csaf/csaf_hashes.py`
  (five-family sidecar generation + verification), `fill_missing_cvss.py`,
  `fill_v4_from_v3.py` and `generate_audit_advisories.py` — underpin it; see the
  new `csaf/README.md`. No change to the viewer binary.

## [0.1.22] - 2026-06-19

### Added

- **Reusable annotation templates.** The Annotations editor gains an *Insert a
  template* dropdown (`<select name="templates">`) populated from a `templates/`
  directory
  (new `--templates-dir` / `GSV_TEMPLATES_DIR`, default `./templates`). Choosing
  an entry fetches its Markdown and inserts it **at the editor's cursor** — or at
  the start of the field when no cursor was placed. Each `*.md` file is imported
  only after passing the **same gate as an uploaded image**: valid UTF-8, a
  non-binary MIME sniff (`infer`), a 1 MiB size cap, and a ClamAV + YARA malware
  scan; anything that fails is logged and skipped, never stored. Survivors live
  in a new `templates` table (cap 500) and are served as inert `text/plain` from
  `GET /annotations/template/{name}` — a database-key lookup, never a filesystem
  path. Requires `--db`. Covered by integration tests, a `proptest` invariant
  and the new `fuzz_validate_template` fuzz target.
- **"Add an annotation" link on control pages.** A control with no annotation
  now shows an empty-state prompt linking to
  `/annotations?kind=control&ref={id}` — the editor pre-targeted to that
  control — so a control-level note can be created straight from the control
  page (previously the only entry point assumed an annotation already existed).

### Testing

- **End-to-end annotation build-sequence test across the catalog.**
  `tests/test_annotation_sequence.rs` reproduces the full editor workflow for a
  control — upload the four unDraw graphics as per-control assets (through the
  real `validate_upload` gate), import all five reusable templates, and save
  nine append-only versions (one `release`, the rest `draft`) — then switches to
  every version through the control page's picker (`/control/{id}?anno_rev=N`)
  and checks both the source and the rendered view. A representative sample runs
  on every `cargo test`; the exhaustive sweep over **all ~1000 controls** also
  runs on every `cargo test` (nine versions + eight image assets per control,
  each control's latest version rendered). The control page's version **picker**
  is asserted too — every revision is a choosable `<option>` and the requested
  one is `selected`. Includes a path-security check that the asset route accepts
  a numeric id only and the template route is a traversal-safe database-key
  lookup.
- **EICAR malware-scan skip test + fixture.**
  `templates/QS-Checkliste_mit_Emoji_with_EICAR.md` embeds the EICAR test
  string. It is valid Markdown, so the static UTF-8/MIME gate accepts it; an
  explicit unit test asserts the importer's **malware scan** detects and skips
  it (never stored) — verified live (`yr` → `TRELLIX_ARC_Malw_Eicar`,
  `imported=5 skipped=1`). The importer's scanner is now injectable
  (`import_from_dir_with`) so the test is deterministic without an installed
  ClamAV/virus-DB.
- **Property-based invariant suite (`proptest`).** `tests/proptest_invariants.rs`
  asserts the security invariants of the untrusted-input functions on every
  `cargo test` run: `render_markdown` never emits a live `<script>`/`<iframe>`
  or `javascript:` href, `safe_rule_basename` is always a single separator-free
  path component, `sanitize_filename` always yields a safe single component, and
  the scanner output parsers never panic or return an empty finding. `proptest`
  is a dev-dependency only (cargo-deny clean).
- **New fuzz target `fuzz_sanitize_filename`** for the upload filename sanitizer
  (untrusted `?name=` → stored filename used in `Content-Disposition` and
  Markdown). `sanitize_filename` is now `pub` so the fuzz target and the
  property tests can reach it.

### Documentation

- **Administrator guide §10 — setting up ClamAV + YARA-X** on Linux, macOS and
  Windows: installing `clamscan` + the `freshclam` virus database and the `yr`
  (YARA-X) CLI, a `PATH`/verify step, and an EICAR end-to-end check. §2.4 links
  to it; "Quick reference" renumbered to §11.
- **`documentation/test_sequences.md`** documents the per-control annotation
  build sequence (graphics, templates, nine draft/release versions) and how the
  integration tests and the populate tool exercise it.
- **`examples/populate_annotations.rs`** — a tool that writes the build sequence
  to a real annotation DB for every control (the tests use a throwaway in-memory
  store), so the per-control annotations become browsable in the running viewer:
  `cargo run --example populate_annotations -- <db>`.

## [0.1.21] - 2026-06-17

### Fixed

- **Annotation image upload no longer fails with "unknown annotation target".**
  The target picker's *Level* select is now only a hint — the actual level is
  **derived from the Id** (`GC` → practice, `GC.1.1` → control, empty → whole
  catalog). Previously, leaving the default *Whole catalog* level selected while
  typing an Id opened a workspace whose attach/save then rejected with `404
  unknown annotation target`. An Id that matches no control or practice now
  falls back to the whole-catalog target and shows a warning naming the bad Id,
  instead of silently opening a dead-end workspace.
- **Uploaded graphics now scale to DIN A4.** The rendered-annotation pane is
  laid out as an A4 page (`210mm` content width); embedded images get
  `max-width:100%` / `max-height` so they fit one page and never overflow the
  editor, plus a print stylesheet (`@page { size: A4 portrait }`) so the
  printout fits the page too.

### Documentation

- **Administrator guide — "Upgrading: replacing the binary" (§3.5).** Documents
  the in-place binary swap (**SIGTERM → `cp` → start**) and explains the
  `Text file busy` (`ETXTBSY`) error you get from `cp`-ing over a running
  executable (`cp` truncates the destination; Linux refuses to truncate a
  running ELF). Notes that `SIGINT` is the graceful in-process hook, the SQLite
  WAL annotation store is crash-safe either way, and a `sha256sum -c` integrity
  check before installing.

### Build

- Ignore `/vendor/` (a stray `cargo vendor` artifact, unreferenced by any
  `.cargo/config.toml`) so the QA gates (`cargo-machete`, `rust-doctor`) no
  longer scan third-party vendored crates.

## [0.1.20] - 2026-06-17

### Added

- **Annotations** — a new write-capable workspace (navbar item right of Export,
  route `/annotations`) for per-level notes on the **catalog, any practice, or
  any control**, selected from one page. Each note is Markdown with a
  **Source ⇄ Rendered** view (live HTMX preview), a `MUSS`/`SOLLTE`/`KANN`
  modal that defaults from the control, a `draft`/`release` toggle, and a
  "copy the control/practice text into the editor" action. Saving is
  **append-only versioning** (every save is an immutable, ISO-8601-stamped
  revision you can browse). **PNG/SVG images** can be attached and are served
  only as downloads, never inline.
  - Storage is a local SQLite file via `--db <FILE>` / `GSV_DB` (default
    `<home>/grundschutz-oscal-viewer/annotations.db`; an empty value disables
    annotations and keeps the viewer read-only). It uses `rusqlite` with a
    statically-linked vendored SQLite, so the single binary still needs no
    system library; confirmed to cross-compile via zigbuild for all six release
    targets.
  - **Security**: Markdown is rendered with the XSS-safe `markdown` crate (no
    inline scripts, so the strict CSP is unchanged). Every uploaded image passes
    one non-bypassable validator — declared-vs-sniffed MIME (`infer`), PNG
    signature/`IHDR`, and an SVG `roxmltree` allowlist that rejects
    `<script>`/`<foreignObject>`/event handlers/external references/DTD. Write
    endpoints (`POST /annotations/{save,preview,upload}`) require a same-origin
    request (`Origin`/`Sec-Fetch-Site`) and cap the body at 2 MiB. The router
    gained `POST`/`PUT` body-handler support; the read-only `GET` path is
    unchanged. API tests live in `test/bruno/collections/annotations/`.
- **Upload malware & encoding scanning** — uploaded annotation images now pass
  an extra content-safety layer before they are stored. SVG bytes are
  UTF-8-validated with `simdutf8` (invalid encoding is rejected with `422`),
  and every upload is scanned with **ClamAV** (`clamscan`, no daemon needed)
  and **YARA** (`yr` against the
  [YARA Forge](https://github.com/YARAHQ/yara-forge) rule set) on a blocking
  thread. A finding rejects the upload **before** anything is written to the
  database; a scanner that is absent or has no signatures is logged and skipped
  (**fail-open on absence, fail-closed on detection**), so the viewer still runs
  with no scanners installed. The scanners shell out to vetted CLIs on purpose —
  the `yara-x` crate fails `cargo deny` (an `rsa` timing-attack advisory and a
  bundled WASM JIT) and `clamav-client` needs a running `clamd`. Covered by
  `tests/test_scan.rs` (EICAR fixtures under `example/`) and the
  `fuzz_scan_output` target.
- **In-app YARA rules auto-download.** When the YARA rules directory is empty,
  the viewer now fetches the latest [YARA Forge](https://github.com/YARAHQ/yara-forge)
  release itself at startup, **verifies the SHA-256 digest** from the GitHub
  release metadata, and unzips it into `<home>/grundschutz-oscal-viewer/yara-rules`
  (override with `--yara-rules` / `GSV_YARA_RULES`). Release discovery, digest
  verification, and the unzip ([`lo_zip`], in-process) are Rust; only the two
  HTTPS byte transfers are delegated to `curl` with the BSI-TR-02102-2 hardened
  recipe (skills/downloads forbids a hand-rolled HTTP/TLS loop in any language).
  Extraction is hardened against zip-slip (basename-only writes), zip bombs (a
  central-directory size pre-scan plus per-file/total decompression caps), and
  partial writes (files are staged and atomically renamed into place). The
  server begins listening **before** the fetch runs and the fetch is best-effort
  and time-bounded, so an offline boot is never blocked. Disable it with
  `--no-fetch-yara-rules` / `GSV_NO_FETCH_YARA_RULES` (air-gapped hosts can
  still populate rules with `scripts/fetch_yara_rules.sh`). Covered by
  `tests/test_yara_fetch.rs` (a live `--ignored` end-to-end test) and the
  `fuzz_yara_entry_name` zip-slip fuzz target.

## [0.1.19] - 2026-06-16

### Changed

- **Meilisearch is now opt-in (disabled by default).** Previously the client
  targeted `http://localhost:7700` out of the box; it is now off unless a URL
  is given via `--meili-url` / `MEILI_URL`. The **built-in search is the
  default** and remains the automatic fallback whenever Meilisearch is unset
  or unreachable (per-query errors fall back transparently). This removes the
  startup "meilisearch not reachable" warning for the common single-binary
  case where no Meilisearch server is run.
- The fuzz smoke gate (`scripts/run_exhaustive_tests.sh`) now **auto-discovers**
  every registered target via `cargo fuzz list` instead of a hard-coded list
  (which had drifted out of date), and fails closed if enumeration returns no
  targets.

### Added

- **Three new fuzz targets**, bringing the suite to 15: `fuzz_meili_ca` (the
  `--meili-ca` PEM parser), `fuzz_meili_url` (`--meili-url` parsing and the
  http/https transport policy) and `fuzz_export_pdf` (the hand-rolled PDF
  renderer's byte/glyph/CMap assembly). The existing `fuzz_export` target now
  also covers the `/export/download` (`build_download`) path.

### Removed

- The unused `lo_core` direct dependency — the export path uses `lo_writer` and
  `lo_zip` only. Confirmed unused by both `cargo-machete` and `rust-doctor`.

### Fixed

- **`rust-doctor.toml` now parses.** It carried `[advisories]` / `[output]`
  blocks (cargo-deny configuration that belongs in `deny.toml`); those unknown
  sections made rust-doctor reject the whole file and silently fall back to
  defaults, quietly disabling the intended `fail_on = "warning"` policy. The
  misplaced blocks were removed so the strict policy is actually enforced.

## [0.1.18] - 2026-06-16

### Added

- **ZIP bundle export** — the `/export` page gains a *ZIP bundle* format that
  packages the selected formats (JSON / Markdown / ODT / PDF) into one `.zip`,
  or all four when ZIP is the only choice. The archive is built in-process with
  `lo_zip` (the zero-dependency, MIT libreoffice-rs ZIP writer already in the
  tree — no new dependency for cargo-deny), and like every other artifact the
  `.zip` is written into the export directory with its checksum sidecars. Each
  needed format is rendered exactly once even when it is both requested
  individually and bundled.
- **Direct download from `/export`** — a second **Start download** button
  (route `GET /export/download`) streams the export straight to the browser as
  a file attachment (`Content-Disposition: attachment`) — a single document
  when one format is chosen, otherwise a ZIP bundle — **without** writing
  anything to the server-side export directory. *Start export* keeps its
  server-side-write behaviour (with sidecars).

### Fixed

- **Concurrent ODT export race** — `lo_writer`'s ODT path round-trips through a
  timestamp-named temp file (`lo_writer_<nanos>.odt`); on a coarse clock two
  simultaneous ODT renders could collide on the same name and fail with a
  spurious `document rendering failed: No such file or directory`. Calls into
  `lo_writer::save_as` are now serialised with a process-wide lock, so
  concurrent `/export` requests are race-free.

## [0.1.17] - 2026-06-16

### Added

- **`--meili-ca <FILE>` / `MEILI_CA`** — trust a custom PEM CA certificate when
  connecting to an `https://` Meilisearch, so a self-signed or internal-CA TLS
  instance (including a local `https://localhost:7700`) can be used
  **encrypted** without disabling verification. The CA is added on top of the
  system trust store; the connection stays TLS 1.3 only. Admin guide §4.2.1
  documents the end-to-end setup. (Meilisearch itself remains **enabled by
  default** — the client targets `http://localhost:7700` unless an empty value
  disables it; you only need a running Meilisearch server to speed up search.)

## [0.1.16] - 2026-06-15

### Tests

- `tests/test_export_pdf.rs`: assert the injected section-heading labels
  (notably `Guidance (Erläuterung)`, rendered in the bold heading font) keep
  their umlaut even for controls whose own text contains none — closing a gap
  in the exhaustive per-control umlaut sweep (which only checked umlauts that
  appear in each control's source text).

### Note

- The faithful-Unicode PDF export (umlauts `ä ö ü ß`) landed in **0.1.15**.
  If a PDF still shows dropped umlauts (e.g. `Erläuterung` → `Erluterung`), it
  was generated by an older binary — re-export with 0.1.15 or later (or
  `cargo install grundschutz-oscal-viewer --force`). The packaged crate is
  otherwise unchanged from 0.1.15.

## [0.1.15] - 2026-06-15

### Added

- **Export menu** (`/export`, beside *Metadata*): export the whole
  Grundschutz++ catalog, a single practice, or a single control as
  **JSON, Markdown, ODT or PDF**, each accompanied by selectable checksum
  **sidecars** (`.sha-256`, `.sha-512`, `.sha3-512`, `.blake3-512`,
  `.shake256-512`, GNU `shasum` format). The document formats are rendered
  in-process by `lo_writer` (the pure-Rust libreoffice-rs document model —
  no external LibreOffice, no shelling out); JSON is the raw OSCAL. Files
  are written into a server-side directory through a single
  capability-scoped `cap_std` handle with `create_new` semantics (existing
  files are never overwritten).
- `--export-dir <DIR>` / `GSV_EXPORT_DIR` configure the Export target
  directory. The default is a per-user `grundschutz-oscal-viewer/export`
  folder under the home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
  on Windows — never a world-writable temp location), created on first use.
- The Export form has an **Overwrite existing files** checkbox (ticked by
  default) so re-running an export replaces its previous files; unticked,
  the export refuses to clobber an existing file and says so clearly
  instead of surfacing a raw `File exists (os error 17)`.
- **PDF export now renders German umlauts faithfully.** `lo_writer`'s PDF
  backend uses a base-14 font with StandardEncoding and silently drops
  non-ASCII characters (`Geschäftsprozesse` → `Geschftsprozesse`). PDF is
  now rendered directly (`src/export_pdf.rs`) with the bundled Roboto
  TrueType font embedded as a Type0 / CIDFontType2 plus a ToUnicode CMap,
  so `ä ö ü ß` render correctly and stay selectable / extractable. Uses
  only `ttf-parser` (zero-dependency, `forbid(unsafe_code)`); Markdown and
  ODT (which already preserved umlauts) stay on `lo_writer`. Covered by
  `tests/test_export_pdf.rs` (an exhaustive sweep that decodes every
  exported control's text back through its ToUnicode CMap) and by
  `scripts/verify_pdf_umlauts.sh`, an independent cross-check that extracts
  the PDF text with `unpdf` (PDF.js) and asserts all seven umlauts survive.
- `tests/test_export.rs` — an exhaustive export suite driven through the
  real router: the form, every format, the five sidecars (content checked
  against the digests), every practice as JSON, the validation paths, the
  unknown-token handling and the overwrite refusal.
- Fuzz targets `fuzz_sidecar` (digest width/charset/determinism + token
  selection) and `fuzz_export` (format-token parsing + scope resolution +
  JSON building, asserting sanitised single-segment filenames — no
  traversal).

### Changed

- **Meilisearch is now enabled by default** at `http://localhost:7700`.
  Pass an empty `--meili-url=` (or `MEILI_URL=`) to disable it. The client
  is rustls-protected: HTTPS for any non-loopback host, plain HTTP only for
  loopback.
- **TLS 1.2 removed everywhere.** The server and the Meilisearch client now
  negotiate **TLS 1.3 only** (the rustls `tls12` feature is no longer
  enabled); every older protocol is refused outright.

## [0.1.14] - 2026-06-13

### Added

- Control pages now offer a **View JSON / Download JSON** action below the
  raw-OSCAL card (the same widget the ndaal advisory drill-downs use).
  `GET /control/{id}/raw.json?download=1` serves the control as a file
  attachment — `Content-Disposition: attachment; filename="<id>.json"`
  with the id lowercased and sanitised (`KONF.2.1` → `konf.2.1.json`) —
  while the plain endpoint stays inline for in-browser viewing.
- Control-list and practice pages now surface the **mapped control**, not
  just the framework name: each framework badge reads e.g.
  `github-security-controls · GH-CHG-01 +6`, with the full reference list
  in its `title` tooltip.
- `scripts/quality_gates.sh` — a canonical quality-gate runner adapted for
  this single-crate, no-database app (native fmt / clippy / test /
  doctest / doc / htmlhint / oxlint / fuzz / live-sweep plus a convention
  dispatcher to `tests/scripts/test_<slug>.sh`), with the reference's
  `--strict / --fast / --rest / --only / --list` profiles.
- `tests/scripts/test_settings_toggle_combinatorics.sh` — a headless-Chrome
  (DevTools-Protocol) test that combinatorially toggles the Settings
  frameworks and asserts each mapping row's visibility and each toggle's
  checked state.

### Changed

- The Settings framework panel is now a multi-column grid that shows every
  framework at once, instead of a single scrolling column.

### Fixed

- Dropped the yanked `time 0.3.48` / `time-core 0.1.9` (transitively via
  `rcgen`); `cargo deny check` is clean again.
- `run_exhaustive_tests.sh`: the HSTS live-sweep check no longer false-fails
  under `set -o pipefail` — `curl -I` (HEAD) exits 16 over HTTP/2 after
  printing the headers, which masked the present header; switched to a
  body-discarding GET + `-D -`.
- `frameworks::parse` moves the parsed CSV fields into each toggle instead
  of cloning them inside the loop.

## [0.1.13] - 2026-06-13

### Added

- Full framework toggle universe: the Settings menu now lists ~150
  compliance frameworks (the CISO-Assistant framework set, with flags),
  embedded as `data/frameworks.csv` (slug, display, default flag). The
  dropdown is a fixed-height scrollable panel with a search box and
  All / None / Reset buttons; a specific subset (ISO 27001, NIS2,
  PCI DSS 4.0.1, GDPR, DORA, CRA, BSI Kompendium, BSI C5, BSI
  Mindeststandard Cloud, NIS2 IR, OWASP ASVS 5, and our GitHub
  crosswalk) is enabled by default, every other framework off.
- Runtime mapping pack: `--mappings-dir <DIR>` / `GSV_MAPPINGS_DIR`
  loads extra mapping CSVs at startup through a capability-based cap-std
  handle, merged with the embedded crosswalk. This keeps any
  separately-licensed (e.g. auto-generated, AGPL) mapping data out of
  the Apache-2.0 binary while still letting the viewer display it.
- Each control page now shows, per framework, **the corresponding
  control** (its reference id and name) plus a similarity-score badge
  and an "auto-generated — verify before use" caveat. The CSV format
  gains a sixth `score` column; `framework` is now a stable slug
  matching `data/frameworks.csv`. See
  `documentation/framework_mappings_pack.md` for the pack contract and
  generator design.

### Changed

- Mapping framework keys are slugs (e.g. `iso27001`) rather than display
  strings; the embedded GitHub crosswalk and `scripts/import_mappings.py`
  were re-keyed accordingly. Default-off framework rows/badges render
  with `d-none` (hidden until enabled); per-framework visibility is
  remembered per browser as overrides on top of each framework's default.

## [0.1.12] - 2026-06-12

### Added

- Exhaustive test sequence: `tests/test_exhaustive.rs` sweeps the whole
  surface in-process (every practice page, every control's `raw.json`,
  every practice × modal-verb × security-level filter combination, the
  full pagination range, every embedded static asset including the
  icon-font aliases, `HEAD` on every registered route, a search per
  practice id and every mapped control's mapping table).
- `scripts/run_exhaustive_tests.sh` (`just exhaustive`) chains the full
  sequence: QA gates → test suite → optional fuzz smoke → release
  build → live HTTPS sweep against a running instance → **testssl.sh
  port check**, which asserts the served port offers TLS 1.3 and
  refuses every older protocol (TLS 1.2/1.1/1.0, SSLv2/v3).

### Changed

- **The viewer now serves HTTPS only.** A TLS 1.3 listener built on
  rustls (aws-lc-rs provider with `prefer-post-quantum`, so the
  X25519MLKEM768 post-quantum hybrid key-exchange group is offered
  first) replaces the previous plaintext HTTP listener — same design as
  ndaal `vulnerability-lookup-rs`. A self-signed certificate for
  localhost is generated with `rcgen` at every start-up (45-day
  validity), HTTP/1.1 and HTTP/2 are offered via ALPN, and a 10 s
  handshake timeout guards the accept loop. `Strict-Transport-Security`
  is sent on every response again (it was dropped while the listener
  was plain HTTP). Browsers show a one-time self-signed-certificate
  warning; scripted clients use `curl -k`. TLS listener pins ported
  from vl-web `test_pqc_kex.rs`, plus a live in-process handshake test
  asserting TLS 1.3 + ALPN h2 + the negotiated PQC group.

## [0.1.11] - 2026-06-12

### Added

- Pre-built release binaries for all six supported platforms committed
  under `release/`, with `SHA256SUMS` and a usage/verification README.
- `scripts/import_mappings.py` (`just import-mappings`) converts the
  GitHub Security Control Catalog under `import/` into viewer mappings:
  it pivots on the `map_bsi_grundschutz_pp` column and attaches, per
  pinned Grundschutz++ id, the GitHub control plus that row's NIS2,
  DORA, ISO/IEC 27001, C5 and Mindeststandard references. Placeholder
  values (`TBD`, `to pin`) are skipped, unknown control ids are warned
  about — a no-op until ids are pinned in the import CSV.
- Cross-framework mappings imported from CSV: every
  `data/mappings/*.csv` (format
  `control_id,framework,reference,title,url`) is embedded at build time
  and shown on every level of the control hierarchy — a "Framework
  mappings" table on each control page (nested sub-controls included)
  and framework badges on the controls list and practice pages. A new
  navbar **Settings** menu (left of the theme toggle and Info) offers a
  checkbox per framework — all enabled by default; disabling one hides
  that framework's mappings (remembered per browser). Ships with
  clearly-marked illustrative example mappings for DORA and
  ISO/IEC 27001:2022 — replace them with reviewed mappings.
- Published on crates.io as `grundschutz-oscal-viewer`
  (`cargo install grundschutz-oscal-viewer`); the package whitelist
  ships only sources, templates, assets and the BSI catalog — release
  binaries, docs and tooling stay out. The crate licence is declared as
  the composite of the packaged contents
  (`Apache-2.0 AND CC-BY-SA-4.0 AND MIT AND 0BSD AND OFL-1.1`).
- Fuzzing harness (`fuzz/`, cargo-fuzz, libFuzzer) with eight targets
  covering every untrusted-input surface: percent decoding, query
  parsing, static-path lookup (traversal property), OSCAL parameter
  substitution, catalog JSON parsing, built-in search, filter +
  pagination invariants and the encode/decode round-trip used by all
  filter links. `just fuzz-build` / `just fuzz <target>` /
  `just fuzz-smoke`.
- Info-menu unit tests adapted from ndaal `vulnerability-lookup-rs`:
  the Impressum's legally required sections, entity facts, disclaimers
  and external profiles; navbar/footer reachability of `/imprint` and
  `/changelog`; full, untruncated changelog rendering in lock-step with
  the on-disk `CHANGELOG.md`.

### Changed

- Real framework mappings: the empty `map_bsi_grundschutz_pp` column of
  the GitHub Security Control Catalog (`import/`) is now populated for
  all 91 GitHub controls (domain-level crosswalk to validated
  Grundschutz++ control IDs), and `import/generate_catalog_md.py` reads
  and writes under `import/` (it previously pointed at non-existent
  `data/` and `documentation/` paths). Running
  `scripts/import_mappings.py` embeds **211 mappings** across five
  frameworks (GitHub Security Controls, NIS2, ISO/IEC 27001:2022, BSI
  Kompendium 2023, BSI Mindeststandard Cloud); the illustrative
  DORA / ISO toy example CSVs are removed.
- Settings dropdown layout fixed: each framework toggle is now a clean
  row with the name left and the switch right-aligned, on a dropdown
  wide enough to keep long framework names on one line.
- Requirement-level colours swapped: MUSS badges, bars and stat cards
  are now green, KANN red (SOLLTE stays yellow).

### Fixed

- Navbar dropdowns (Info, Practices, mobile menu) did nothing: the
  vendored `bootstrap.bundle.min.js` was a re-prettified variant whose
  broken minification threw at load time, so Bootstrap never
  initialised. All vendored text assets (Bootstrap 5.3.3 CSS/JS,
  Bootstrap Icons 1.11.3 CSS, htmx 2.0.4) are now byte-identical to the
  official distributions (verified against published SRI hashes) and an
  integrity test pins their SHA-256 values.
- `/static/` asset URLs now carry a `?v=<version>` cache-buster so a
  binary upgrade can no longer leave stale CSS/JS in browsers
  (assets are cached for an hour).

## [0.1.10] - 2026-06-12

### Added

- Single-binary viewer for the official BSI Grundschutz++ OSCAL catalog
  (`BSI-Bund/Stand-der-Technik-Bibliothek`, Anwenderkatalog, OSCAL 1.1.3).
- Complete catalog JSON embedded at compile time — a binary copy is a
  full installation; no network access, database or install step needed.
- Web UI served on localhost (hyper + Askama + Bootstrap 5 + HTMX),
  design and Info menu carried over from ndaal `vulnerability-lookup-rs`:
  About, System info, Privacy, Security, License, Imprint, Changelog.
- Control list with filters for practice (domain), modal verb
  (MUSS / SOLLTE / KANN), security level (`normal-SdT` / `erhöht`),
  effort level (0–5), tag and free text.
- Control detail pages showing every JSON field: statement (with OSCAL
  parameter substitution), guidance, props with namespaces, params,
  tags, nested sub-controls and the raw control JSON.
- Practice overview and per-practice pages with the full group tree.
- Statistics page with catalog-wide distributions.
- Catalog metadata page (raw OSCAL metadata + back-matter) and
  `/catalog.json` download of the embedded catalog.
- Full-text search: built-in engine (always available, offline) plus
  optional Meilisearch integration (`--meili-url` / `MEILI_URL`); the
  index is populated automatically at startup. Results report the true
  match count ("showing the first 50"); query length and token count
  are capped so a single request cannot buy unbounded CPU time.
- `--export <dir>` writes the embedded catalog through capability-based
  cap-std directory handles with create-new semantics;
  `scripts/update_catalog.sh` refreshes the vendored catalog.
- Security headers on every response: X-Frame-Options DENY, a CSP whose
  script-src pins the two inline scripts by SHA-256 hash (no
  `unsafe-inline` for scripts), nosniff, Referrer-Policy,
  Permissions-Policy and COOP/CORP/COEP.
- Server hardening: connection cap (512) and a 30 s header-read timeout
  against slowloris; `HEAD` served wherever `GET` is (RFC 9110), `405`
  responses carry the `Allow` header.
- REUSE licensing: `.license` sidecars for the embedded catalog and all
  vendored UI assets, full license texts under `LICENSES/`.
- Cross-platform release builds via cargo-zigbuild
  (`scripts/build_release_targets.sh`, `just build-all`): Linux
  (x86_64/aarch64, static musl), Windows (x86_64/aarch64) and macOS
  (x86_64/aarch64), with SHA-256 checksums in `dist/SHA256SUMS`.