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