Skip to main content

Changelog

<!--
SPDX-License-Identifier: Apache-2.0
SPDX-FileCopyrightText: 2026 ndaal Gesellschaft für Sicherheit in der Informationstechnik mbH & Co KG, Cologne
SPDX-FileCopyrightText: Author: Pierre Gronau <Pierre.Gronau@ndaal.eu>
-->

# Changelog

All notable changes to the ndaal SBOM Auditor are documented in this
file. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this
project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [1.6.32] - 2026-10-11

A dependency update for the ODT and DOCX export, a page description on every
page, and an HTML and JavaScript lint gate that now checks what it reports.

### Changed

- **lo_writer 0.5.4 → 0.5.6** (with lo_core 0.5.3 → 0.5.5), the library
  behind the ODT and DOCX export. lo_core 0.5.5 brings in fontdb 0.24.0,
  fontconfig-parser 0.5.8, slotmap 1.1.1 and subsetter 0.2.6, and with
  fontconfig-parser a second roxmltree (0.20.0, next to the direct 0.21.1).
  `deny.toml` now allows that duplicate (disjoint subtrees); the seven new
  crate versions carry time-boxed cargo-vet exemptions. No known
  vulnerability affects the old versions; CSAF advisory ndaal-sa-2026-746
  (CVSS 3.1 and 4.0: 0.0).

### Fixed

- **Every page has a `<meta name="description">`**, in the page's language:
  the home page's one-line summary, which exists in all 49 interface
  languages.
- **Attribute order on the rendered pages** follows htmlhint's
  `attr-sorted` rule (34 tags on the settings, tools, run-status and
  support-matrix views). The two inline scripts in the layout and the
  annotation editor use `querySelector` and `dataset`; the inline scripts'
  CSP hashes follow.

### Security

- **foxguard 0.14.0 on `src/`: 11 findings triaged, none exploitable.**
  The two `js/no-ssrf` findings are browser-side fetches to fixed
  same-origin paths, already confined by CSP `connect-src 'self'`; the nine
  `rs/no-path-traversal` findings are the traversal guards themselves,
  string handling that opens nothing, or operator-supplied paths. Each site
  carries its reason and foxguard's ignore directive; `foxguard src`
  reports 0 findings.

### Tests

- **The HTML and JavaScript lint gate checks what it reports.**
  `tests/scripts/test_html.sh` wrote its reports outside the repository,
  linted the templates without their Askama-aware configuration, and
  swallowed htmlhint, oxlint and fta findings, so `--strict` could not
  fail. It now finds the repository with git, uses
  `src/templates/.htmlhintrc` and `.oxlintrc.json`, counts every finding,
  and skips `playwright-report/`, `test-results/` and `skills/`. oxlint's
  pedantic group is on. Seven behaviour tests run the script in a sandbox
  repository with stub linters.
- **New: `tests/scripts/generate_sbom_malware_test_scenarios.sh`** writes
  three deliberately suspicious SBOMs (CycloneDX 1.5, SPDX 2.3, SPDX 3.0
  JSON-LD), metadata only, for testing how the auditor and other SCA tools
  handle supply-chain malware indicators.

### Documentation

- **Test inventory for 1.6.32:** 112 integration tests (one more, for the
  page description) and 5,769 bats tests in 170 files (65 more: the
  generator's 58 and the lint gate's 7). The other suites are unchanged.

## [1.6.31] - 2026-10-08

Analyses that a restart interrupted are run again at start-up instead of
showing "Pending" for ever.

**Upgrade note:** the first start of 1.6.31 runs every analysis that earlier
restarts left pending or running. On an instance with a large backlog the
analyzers stay busy for a while after the upgrade; the log line "re-running
analyses a restart interrupted" gives the number of runs and SBOMs.

### Fixed

- **A restart no longer strands queued analyses.** An analysis stored its
  runs as pending, but only the in-memory task of the process that queued
  them ever finished them. After a restart every run still pending or
  running stayed so for ever: the detail page kept polling, the SBOM was
  never analysed, and `reanalyze_sboms_via_api.sh` skipped it as still in
  flight. On sbom.ndaal.eu a restart during a bulk upload left 78 SBOMs in
  this state. At start-up the server now resets such runs to pending and
  runs them again in the background, one SBOM after another, each followed
  by the usual enrichment. The existing rows are re-used, so no SBOM gains
  a second set of runs; a run naming a tool this build does not know is
  marked failed.

### Documentation

- **Test inventory recounted on this tree.** The Rust rows now come from
  cargo's own test lists (`cargo test -- --list`): 539 unit tests and 111
  integration tests. The 1.6.30 inventory overstated both: that tree had 533
  and 109, not 538 and 110. Bats: 5,704 tests in 169 files. The other suites
  are unchanged.

## [1.6.30] - 2026-10-08

A re-upload of a stored SBOM now shows on the dashboard, large uploads get
10 minutes instead of two, three dependencies are updated (hyper, zeroize,
h2), and the README counts every test suite.

### Added

- **`tests/scripts/reanalyze_sboms_via_api.sh`** re-runs the analyzer ensemble
  for every SBOM stored on an instance, through its HTTP API (`--base-url`).
  It skips SBOMs whose runs are still pending or running, because a second
  analyze call would queue them twice (`--include-in-flight` overrides),
  retries server errors, and offers `--dry-run`, `--limit`, `--pace` and a
  TSV `--log`. On sbom.ndaal.eu it queued 922 SBOMs and skipped 198 still in
  flight, with no failures.

### Fixed

- **A re-upload is visible again.** Uploading an SBOM that is already stored
  used to resolve silently to the old row, which kept its first-ingest time
  and stayed buried on the newest-first dashboard, so the upload left no
  trace. The dashboard now lists the most recently uploaded SBOM first, in
  a new Last uploaded column, and a re-upload moves its SBOM back to the
  top; the detail page shows Last uploaded next to the unchanged Ingested
  time. Re-importing a folder does not reorder the list. The store gains
  schema v9 (`last_uploaded_at`, `upload_seq`); existing SBOMs keep their
  current order. The label is translated in all 49 UI languages (machine
  translations pending native review).
- **Large uploads no longer time out after two minutes.** `POST /upload`
  may now take up to 10 minutes; every other request keeps the 120-second
  limit. SBOMs of several hundred megabytes were refused with `408 Request
  Timeout` before they finished uploading.

### Changed

- **Semver-compatible dependency updates.** hyper 1.11.1 → 1.12.0 (with want
  0.3.1 → 0.3.2), zeroize 1.9.0 → 1.9.1 and h2 0.4.19 → 0.4.20 (linked
  through hyper's HTTP/2 server). No known vulnerability affects the old
  versions; each update has a CSAF advisory, ndaal-sa-2026-741 to
  ndaal-sa-2026-743 (CVSS 3.1 and 4.0: 0.0). Held back: arbitrary 1.5.0 and
  bolero 0.13.7 (test tooling only), whose input generator together
  intermittently allocates without bound and hangs the bolero property tests;
  and lo_writer 0.5.6, which pulls in a second roxmltree that the dependency
  policy (`deny.toml`) bans as a duplicate.

### Documentation

- **Test inventory in the README (all five languages).** The Testing section
  now counts every suite, measured on this tree: 538 unit tests (72 of them
  proptest properties), 110 integration tests, 8 cargo-fuzz targets seeded
  with 5,348 corpus files, 4 bolero harnesses, 3 atheris targets for the CSAF
  Python tools, 2 Kani proofs, 10 Loom models, 82 compile-time invariants, 8
  insta snapshots, 4 BDD scenarios, 88 sqllogictest records, 69 Bruno
  requests, 49 Playwright tests, 4 Magnitude checks, 2 Goose load scenarios
  and 5,674 bats tests.

## [1.6.29] - 2026-10-07

The Info menu gains the Developer Guide and a new Troubleshooting Guide, the
Tools page shows which languages and package managers each analyzer supports,
the README is now available in all five languages, and the dependency policy
carries no stale entries. No change to analysis, enrichment or exports.

**Upgrade note:** `--port` and `SBA_PORT` now refuse ports 0 to 1024 (see
Changed). A host that listens on a privileged port directly, for example 443,
will not start after the upgrade: move the auditor to a port from 1025 up and
put a reverse proxy in front of it for 443.

### Added

- **Info → Developer Guide (`/developer`) and Info → Troubleshooting Guide
  (`/troubleshooting`).** Both close the Info menu, in that order, below the
  User Guide, and render their embedded document the same way as the README
  and the other guides. The menu labels are translated in all 49 UI languages
  (machine translations pending native review, like the other new strings;
  least certain: se, sma, smj, la, rm, lb).
- **A Troubleshooting Guide** (`documentation/Troubleshooting_Guide.md`): for
  each problem area — start-up and listening, the browser, uploads and
  imports, analysis, enrichment, search, exports, the store, self-update,
  heavy load, language and theme — the symptom, what to check, the likely
  cause and the fix, quoting the log lines and messages exactly as the
  auditor prints them. The Administrator Guide's troubleshooting table now
  points to it.
- **Tests for the two pages, matching the README and changelog pages:** the
  embedded documents must equal the files on disk and be non-trivial, each
  page renders its full document in the scrollable container, the Info menu
  ends User → Developer → Troubleshooting with nothing after, both labels are
  defined and translated in every language table, both routes answer in the
  route integration test, and every document the info pages embed is listed
  in `Cargo.toml`'s `include` (a missing one compiles here but breaks
  `cargo install` from crates.io). Bruno requests `info/11-developer.bru` and
  `info/12-troubleshooting.bru` assert the page heading and the document's own
  title, and a Playwright test follows the menu to both pages.
- **Tools → language and package-manager support.** Below the live analyzer
  inventory, which is unchanged, the Tools page loads a support matrix with
  htmx (`GET /tools/support-matrix`, swapped in on load): which languages and
  package managers each of the nine analyzers reads particularly well, which
  tool to use for which job, and the tool versions the matrix was written
  against, as HTML tables with numbered sources that link to the upstream
  documentation. The fragment is a hand-written partial
  (`src/templates/partials/support_matrix.html`) that follows
  `documentation/sbom-tools-support-matrix.md`; the card heading and its
  loading text are translated in all 49 UI languages (machine translations
  pending native review), the matrix itself is English and marked `lang="en"`.
  Tests: a drift test fails when any heading, paragraph, list item, table
  cell or source of the Markdown document is missing from the partial
  (checked by deleting one cell and one link); every analyzer in `Tool::all()`
  has a row in both tables; every footnote reference and back-link lands on
  an id; every outside link is HTTPS with `rel="noopener noreferrer"`; the
  partial is a fragment, not a page; the page loads it below the inventory
  table rather than inlining it; both labels are translated everywhere. The
  route test checks the page in English, German and French and the fragment
  itself. Bruno `pages/04-tools.bru` anchors on the card's icon, the fragment
  URL and the htmx trigger rather than English text (a full run leaves the
  i18n folder's language cookie behind), and the new
  `pages/13-tools-support-matrix.bru` checks the fragment: no page shell, two
  rows per analyzer, every footnote link landing on an id, every outside link
  HTTPS with `rel="noopener noreferrer"`. Playwright checks the swap, the three
  tables, two row headers per analyzer, the order, a footnote jump, that tool
  names keep their case (the app's header style uppercases `<th>`), and that on
  a 390 px phone the matrix scrolls sideways inside its card with readable
  columns instead of squeezing them, while the page itself never scrolls
  sideways.
- **Property tests for the remaining untrusted-input parsers** (item 7 of
  the 2026-09-04 test-sequence audit), 32 properties in nine modules: the
  form and query decoders (`router`; round trip of browser encoding, one pair
  per segment), the language switch's redirect (`routes::lang::return_path`;
  never off-site for any `Referer` bytes, the referer's path kept), path
  confinement (`capfs`; `..` and absolute paths refused, accepted paths
  resolve inside the base, write/read round trip), the encryption-key file
  format (`crypto`; 32 raw bytes or 64 hex digits, nothing else, file and
  memory agree), the command line (`cli`; a flag-table model, unknown options
  and missing values refused, no invented values, `SBA_PORT` never silently
  defaults), the TLS PEM parsers (`tls`; certificates and ECDSA keys found
  among any text, every `RSA PRIVATE KEY` refused), CVSS expansion
  (`csaf::model`; every metric spelled out, none invented — the first tests
  `from_vector` has had), the download allowlist (`export::read_export_file`;
  only listed export files, never traversal, outside, case-variant or
  non-export names), and bulk import (`import`; every SBOM-named file counted
  exactly once, a rescan stores nothing new). Each module's properties were
  shown to fail by a deliberate bug in that module (nine bugs, 13 properties
  red), then the code was restored byte for byte. No property found a defect
  in the existing code; the one committed seed,
  `proptest-regressions/cli.txt` (port 0), comes from the red run of the new
  port rule below.

### Fixed

- **The crates.io release stages every embedded document.**
  `release/create_release_on_crates.io.sh` copied only the Administrator and
  User Guides into its publish staging, but the binary also embeds the
  Developer and Troubleshooting Guides, so `cargo publish`'s verify build
  would have failed on a missing `include_str!` file and stopped the release
  pipeline. Both guides are staged now, and a unit test
  (`the_crates_io_release_stages_every_embedded_document`) requires every
  embedded document in the script's list.
- **`--tls-key` help no longer offers RSA.** The usage text and
  `REFERENCE.md` listed PKCS#1 (RSA) among the accepted key formats, although
  the server refuses an RSA key; they now say PKCS#8 or SEC1 (EC), and that
  PKCS#1 (RSA) keys are refused. A usage test pins it.
- **The Administrator Guide no longer names a stale current version** (it
  said 1.4.14); it points to the About page and `sbom-auditor --version`.
- **The Playwright export test exports the two formats it names.** Every
  format checkbox starts ticked, but the test only ticked SARIF and Markdown,
  so it exported all eight, LibreOffice-rendered ones included, and ran close
  to its timeout under load. It now unticks them all first and checks that
  exactly two are selected.
- **A fresh install can save its Settings page.** The built-in upload limit
  has been 10 GiB since 1.6.26, but the form and the server-side validator
  still accepted at most 1 GiB, so a store whose settings were never saved
  showed a value the browser refused to submit, and a scripted save of the
  untouched form answered `422`. The accepted range is now `1024` to
  `10737418240` bytes, and one pair of constants
  (`UPLOAD_MAX_BYTES_MIN` / `UPLOAD_MAX_BYTES_MAX` in `src/store.rs`) drives
  the default, the validator and the form's `min` / `max`, so they cannot
  drift apart again. New tests render the Settings page with the defaults
  and require every number field to lie within its own `min` / `max`, push
  the default numbers through the validator, and pin the upload range at
  both ends.

### Documentation

- **A Bruno skill** (`skills/bruno/SKILL.md`) for the Bruno API collections:
  the folder layout, the baseline cases every endpoint needs, the HATEOAS
  assertions, environment files, the runner and its sibling bats test, and
  the merge gates, moved out of the shared `CLAUDE.md` (which keeps a short
  pointer). It also records what this release learned: `bru run collections`
  without `-r` runs no request, prints PASS and exits 0; a full run needs a
  fresh store seeded with what the collection expects; and the folders share
  one cookie jar, so a folder that sets the UI language changes what later
  folders render.
- **`test/bruno/run.sh` fails a run that executed no request.** Bruno exits 0
  on a run of 0 requests (a missing `-r`, a wrong path, an empty folder), so
  the runner now writes its own `--reporter-json` report, counts the requests
  in it, and fails on 0 or on a missing report; otherwise it prints the
  count. The new sibling `test/bruno/run.bats` (11 tests, behaviour checked
  through a stub `npm`, no server needed) covers the 0-request and
  missing-report failures, which it watched fail before the guard existed,
  the passing and failing runs, `-r`, and an unknown environment. The real
  runner on a fresh seeded store reports 69 requests, 253/253 assertions.
- **`test/bruno/run.sh`** names the port the development environment uses
  (8680, not 8191) and gives the command that seeds the fresh store the
  `sbom/` requests expect.
- **Spanish and Italian READMEs** (`LEEME.md`, `LEGGIMI.md`), structurally
  identical to `README.md`: same headings, byte-identical code blocks, same
  link targets, as `skills/readme/scripts/check_translation_mirrors.sh`
  checks. All five READMEs now carry the same language line linking all five,
  and list the Developer Guide and the Troubleshooting Guide under
  Documentation. `REFERENCE.md` lists the two new routes.

### Changed

- **Ports 0 to 1024 are refused.** `--port` and `SBA_PORT` now accept 1025 to
  65535 only (`MIN_PORT` in `src/cli.rs`), and the error names that range.
  Port 0 used to be accepted and bound a random port nobody could find, while
  the `SBA_PORT` message already promised 1 to 65535; 1 to 1024 are the
  privileged range a service should not bind itself. To serve on 443, put a
  reverse proxy in front. The usage text, the READMEs, `REFERENCE.md` and the
  Administrator and Troubleshooting Guides state the range. Tests: a unit test
  for typical privileged ports and both ends of the range, a property that
  every port from 0 to 1024 is refused however it is spelled (leading zeros,
  blanks), and a process-contract test that `--port 443` exits non-zero,
  naming the range, before anything starts.
- **`app.css` passes the repository's stylelint configuration** (190 findings
  to none). Rules no template, script or Rust code uses are removed: the
  Grundschutz requirement badges, the stat cards, the search hero, the
  practices and settings mega-menus, the settings toggles, the annotation
  print layout and four unused colour tokens. The live rules select by class
  or attribute instead of element (`app-body`, `app-main`, `app-footer` and
  `page-loader` classes in the base layout; table headers as
  `.table [scope]`, since every `<th>` carries `scope`), use flow-relative
  properties and `dvb`/`dvi` units, range media queries, no `!important`,
  and give each Bootstrap custom property its light-theme value as a
  fallback; the header is the canonical SPDX block. No visible change: 124
  screenshots (every page in English and right-to-left Urdu, light and dark,
  desktop and phone, plus the open language menu) match the previous build
  pixel for pixel, except a live value on `/system-info` that also differs
  between two runs of the same build. Two new route tests keep the
  assumptions true: the base layout carries its class hooks, and every
  `<th>` in the templates declares `scope`.
- **`deny.toml` carries no stale entries.** `cargo deny check` passed but
  warned about six entries that matched nothing: four allowed licences no
  dependency uses any more (`BSD-2-Clause`, `Unicode-DFS-2016`, `BSL-1.0`,
  `OpenSSL`), a duplicate-version skip for `r-efi`, which cargo-deny now sees
  in one version only, and the ignore for `RUSTSEC-2026-0253`. All six are
  removed; `cargo deny check` now passes with no warnings. The lru advisory
  `RUSTSEC-2026-0253` still applies to lru 0.16.4 through tantivy, but
  cargo-deny's default `unsound` scope covers workspace crates only, so it
  never reported it (with `unsound = "all"` it fails, as checked). The ignore
  therefore stays in `.cargo/audit.toml`, where `cargo audit` scans the whole
  lockfile and still needs it.

## [1.6.28] - 2026-10-05

No change to the analyzers or the exports. This release fixes a macOS crash in
vulnerability enrichment, updates the dependencies within their semver
ranges, translates the enrichment column labels into every UI language, and
documents how to operate and read enrichment.

### Changed

- **103 dependencies updated within their semver range** (`cargo update`),
  among them tokio 1.53.1 → 1.53.2, tokio-rustls 0.26.5 → 0.26.6, hyper-rustls
  0.27.9 → 0.27.10 and hyper-util 0.1.20 → 0.1.21, and for development insta
  1.48 → 1.49. The `Cargo.toml` minimums for hyper-rustls and insta are raised
  to match. None of the bumps fixes a known vulnerability: `cargo audit` of the
  pre-update `Cargo.lock` against the RustSec database of 2026-10-03 reported
  nothing. Each bump is recorded in its own CSAF 2.1 advisory,
  ndaal-sa-2026-384 to ndaal-sa-2026-486, scored CVSS 3.1 0.0 and CVSS 4.0 0.0
  (NONE).
- The lockfile also gains seven transitive crates (icu_segmenter,
  icu_locale_fallback and their two data crates, core_detect,
  multiversion_no_op, zlib-rs), drops two (tinyvec_macros, unicode-linebreak),
  and carries additional versions of hashbrown and synstructure.
- io-extras keeps resolving windows-sys 0.59. `cargo update` had moved it to
  windows-sys 0.60.2, which drags in a second set of the nine `windows-targets`
  / `windows_*` crates and fails the `cargo deny` duplicate ban; 0.59 is what
  the previous lockfile used and is already in the tree.
- `lo_writer` stays at 0.5.4: 0.5.6 pulls roxmltree 0.20 next to the 0.21 this
  crate uses, which the `cargo deny` duplicate ban rejects.
- The bolero property-testing family stays at 0.13.4. With bolero 0.13.7 and
  bolero-generator 0.13.6, `provenant_licence_findings_are_store_valid` hung
  for over 50 minutes inside bolero's own random-input generator (a buffer
  fill in `bolero_generator::driver::rng`), where the same test binary had
  just passed in about a second. The hang depends on the random seed, so the
  bump is held back rather than shipped with a flaky property test.

### Fixed

- **macOS: concurrent enrichment could crash the server.** Every enrichment
  pass reloaded the system CA roots through Security.framework, which is not
  safe to enter from several threads at once. Two analyses finishing together
  crashed a 1.6.27 server with SIGSEGV in `SecTrustSettingsCopyTrustSettings`,
  and concurrent loads could also come back empty, leaving findings
  `Not enriched`. The roots are now loaded once per process behind a lock and
  shared; a failed load is retried on the next pass rather than cached. Linux
  reads the CA bundle from files and was not affected. A unit test (eight
  threads must receive one shared store) and a Loom model (one load, never
  two at once, in every interleaving) pin the fix.
- **The `cargo deny` gate can fail again.** `tests/scripts/test_cargo_deny.sh`
  logged a warning on a non-zero `cargo deny` exit and still exited 0, so a
  `bans FAILED` run during this release's update was reported as PASS. It now
  exits with `cargo deny`'s status; its bats suite pins the fail, pass and
  self-skip paths with a stub `cargo`, replacing a test that had pinned the
  false green.
- **The enrichment column labels are translated.** `Known exploited`,
  `Not enriched`, `Source` and the EPSS `percentile` label existed in English
  only, so all 48 other UI languages showed them in English. Every language
  table now defines all seven enrichment strings (KEV, EPSS and Nuclei keep
  their names), and a test fails if a table lacks one or leaves
  `Known exploited` / `Not enriched` in English. The new strings are machine
  translations, like the rest of those tables, pending native review.
- **Clippy is clean again under Rust 1.99.** The new `clippy::assert_is_empty`
  lint flagged 18 `assert!(x.is_empty())` checks in the test code, which
  failed `cargo clippy -- -D warnings`. They now compare against an empty
  value with `assert_eq!`, so a failure prints what the collection held. Test
  code only; no behaviour change.

### Documentation

- README, LIESMICH and LISEZMOI, the User Guide and the Administrator Guide now
  explain that enrichment lookups come from the server host (TLS 1.3 only,
  system CA bundle), how to check that path with one `curl`, which log lines
  record the outcome, why a KEV badge on a finding without a package usually
  does not apply, and which example SBOM shows KEV, nuclei and EPSS on one
  finding.

Known open:

- **`cargo vet` is red, and its gate runner hides it.** 289 dependency
  versions are neither audited nor exempted (247 before this release's
  update, 42 added by it). `tests/scripts/test_cargo_vet.sh` logs a warning on
  `cargo vet`'s non-zero exit and still reports PASS. The clippy and
  rust-doctor runners do the same: during this release clippy exited 101 on
  the lint above and rust-doctor exited 1, and both gates read PASS (the
  pre-commit hook, which runs clippy itself, caught it). Seven more runners
  log a tool's non-zero exit as a warning and need review: test_bats,
  test_cargo_auditable, test_cargo_fmt_toml, test_cargo_install_update,
  test_cargo_oneway, test_cargo_propagate_features and test_cargo_quality.
- **Enrichment does not work on sbom.ndaal.eu.** Production analyses find CVEs,
  but none is enriched with EPSS, CISA-KEV or nuclei data; the same SBOM is
  fully enriched on a local instance of the same release. Every check possible
  from outside passes, so the cause is on the production host (reachability of
  vulnlookup.ndaal.eu, or the service's CA bundle).
- **Some CVEs are matched against the operating-system release, not a
  package.** `fedora:latest` shows six KEV findings reading
  `affects fedora @ 40` (PHP-CGI and Chrome V8 flaws) and several Ubuntu-based
  images show CVE-2021-3493 the same way, each without a package. CVE-2020-0601, a Windows
  CryptoAPI flaw Go fixed in 1.13.6, is reported against `stdlib@go1.22.7`. All
  are likely analyzer matching false positives; not yet traced to an analyzer.

## [1.6.27] - 2026-10-05

No functional changes. The binary differs from 1.6.26 only in its version
string and in this changelog, which it embeds. Everything below concerns the
repository's quality gates and the Python reference client.

### Fixed

- **The `linters` quality gate passes.** 1.6.26 shipped with it red; all ten
  tools (yamllint, ryl, markdownlint, rumdl, hadolint, htmlhint, oxlint, fta,
  ruff, bandit) now report no findings.
- **The `gitleaks` gate scanned nothing in 1.6.26.** The git-based
  `REPO_ROOT` anchor added in 1.6.26 left the runner's old second assignment
  in place, and re-assigning the read-only variable aborted the script before
  it scanned. The old assignment is removed and a regression test added; the
  runner's stale `.sha3-512` sidecar is regenerated.
- **The `sqlmap` gate never scanned the export download endpoint.** Its
  target was `/export/download?file=cyclonedx.json`, a file the app cannot
  write (it has no CycloneDX export format), so the endpoint answered 404,
  sqlmap tested nothing, and the gate failed as a runner failure on every
  run since at least 2026-09-22. The runner now exports SBOM 1 as Markdown
  first, checks that `sbom-1.md` downloads, and scans that download. Its new
  `test/sqlmap/run.bats` pins the target and the seeding order.
- **The `csaf_ndaal` gate looked for `csaf/` outside the checkout.** It
  counted parent directories for the nvulnlookup layout; it now uses the
  git-resolved `REPO_ROOT`. Its CVSS check also assumed advisories 001 and
  002 are security advisories; they are informational release records,
  which CSAF 2.1 does not allow to carry vulnerabilities, so the check now
  asserts that absence. A missing field fails the check instead of aborting
  the script.
- **132 runner `.sha3-512` sidecars no longer matched their scripts.** The
  1.6.26 `REPO_ROOT` change edited the runners without regenerating them.
  The digests are regenerated and the existing labels kept.
- **`.yamllint` failed its own rules.** It declares two-space indentation and
  was written with four. Reindented; the parsed configuration is identical
  before and after.
- **ruff's configuration contradicted itself.** `select = ["ALL"]` enables
  D203 together with D211, and D212 together with D213, and ruff silently
  picked a winner in each pair. The losing rules are now listed in `ignore`.
  ruff's copyright rule flagged both Python files as missing a notice
  although both carry the SPDX header, so its pattern now also accepts
  `SPDX-FileCopyrightText`. In the reference client, 25 missing trailing
  commas and two over-long comments were fixed.

### Changed

- **htmlhint no longer runs `tag-pair` or `attr-no-duplication` on the
  Askama templates.** Both rules read template syntax such as
  `{% if %}…{% endif %}` as broken HTML, and neither can be made correct for
  a template. Every other rule still runs and the 21 templates scan clean;
  the reason is recorded in `src/templates/.htmlhintrc`.
- **markdownlint accepts either ordered-list style** (MD029
  `one_or_ordered`). The tree uses both repeated `1.`, which is what mdformat
  writes, and incrementing `1. 2. 3.`; requiring either style failed the
  other half. A list with broken numbering is still caught.
- `documentation/wiki/` is excluded from yamllint and ryl. It holds CodeWiki
  scan state that is rewritten on every run, so fixes to it would not
  persist.

Still open, carried forward from 1.6.26:

- `csaf/*.py` (46 files) has never been linted. Adding it is a coverage
  expansion, not a gate fix, and belongs in its own change.
- Several gate runners write into a hardcoded `vulnerability-lookup-rs/`
  subpath inherited from the nvulnlookup layout, creating ~1.8 GB of vendored
  wordlists and reports. Two such trees exist (one inside this checkout, now
  gitignored; one beside it). The runners, not the trees, are the bug.
- The `gitleaks` gate runs again but cannot finish in practical time:
  `gitleaks dir` walks the whole working tree, including `target/`. The
  commits since 1.6.26 were scanned separately and are clean.
- The `.sha3-512` sidecars of `test_cve_api_probe.sh` and `test_zap_scan.sh`
  were already stale before 1.6.26 and are left as found.

## [1.6.26] - 2026-09-22

### Fixed

- **The `.githooks/pre-commit` Rust phase is runnable again.** It had been
  red for every commit touching a `.rs` file or a Cargo manifest, for three
  stacked reasons, none of them introduced by a contributor: `typos` had no
  config and reported 28,896 findings over the whole repo root; clippy 1.98
  added `chunks_exact_to_as_chunks`, which fires on `src/crypto.rs`; and
  `RUSTDOCFLAGS="-D warnings" cargo doc` failed at 13 sites. The hook
  early-exits unless a Rust file is staged, so script- and docs-only commits
  never hit any of them and the breakage stayed invisible.
- **`REPO_ROOT` resolved to the parent of the checkout.** 111 of the 139
  runners under `tests/scripts/` counted three directory levels up from
  their own location — correct for the nvulnlookup layout, which has an
  extra sub-directory, and one level too high here. The visible symptom was
  `devguard check --path "${REPO_ROOT}"` scanning every sibling repository
  on the machine. They now ask git for the root, which is also
  worktree-aware.
- **8 CSAF advisories were not security advisories.** `ndaal-sa-2026-366`
  through `-373` failed CSAF 2.1 validation on an empty `known_affected`
  array. They are release records — no CVE, CVSS 0.0/NONE, and a
  vulnerability titled "No vulnerability" — so there was no affected
  product to name and naming one would have put a false claim in a security
  advisory. They are now `csaf_informational_advisory` with no
  vulnerabilities array, matching the 88 release announcements already
  published that way; the prose that lived under the vulnerability was
  moved into `document.notes` rather than dropped. The validation sweep
  goes from 373/381 to 381/381.

### Changed

- 139 shell runners were aligned with `skills/bash/assets/template.sh`:
  the `dirname --` end-of-options guard, the canonical `print_script_info`
  footer, and the canonical blank-line + `exit 0` ending. The 12 runners
  that print extra fields keep their own bodies.

## [1.6.25] - 2026-09-22

### Fixed

- **`/components` never stopped warning "this search is incomplete" after a
  reindex.** The banner counted SBOMs with no rows in `sbom_components`, which
  conflates "the index has not been built" with "the index was built and the
  SBOM genuinely has no indexable components". An SBOM in the second category
  could never leave the warning: reindexing it wrote zero rows, so the next
  page load counted it again, and the operator was told to rebuild an index
  that had just been rebuilt. On sbom.ndaal.eu that stuck 46 SBOMs in a loop.
  The index now records the fact directly — a new `components_indexed_at`
  column (schema v8) is stamped inside the same transaction that writes the
  component rows, so an empty result is indistinguishable from any other
  completed index. The banner therefore clears when the work is actually done,
  and an SBOM is only ever reported as unindexed when it truly is.
- The v8 migration backfills `components_indexed_at` for every SBOM that
  already has component rows, so an existing database does not have to reindex
  from scratch. It probes `PRAGMA table_info` before the `ALTER TABLE` because
  SQLite has no `ADD COLUMN IF NOT EXISTS`, which keeps the rung idempotent if
  the ladder is re-entered from a rewound `user_version` — a regression test
  pins that.

### Changed

- **osv-scanner 2.6.0 verified.** No code change is required; the existing
  `scan --lockfile <path> --format json` invocation is unchanged and correct.
  2.6.0 does newly log `Starting filesystem walk for root: /` on stderr even
  for `--lockfile`, which reads alarmingly like the directory-walking
  `scan source` form. It is cosmetic — the same run reports `0 dirs visited,
  1 inodes visited`, i.e. only the materialised input file is read. The tool
  spec's doc header now says so, so the next person to see that line does not
  have to re-derive it.

### Documentation

- The analyzer table in `README.md`, `LIESMICH.md` and `LISEZMOI.md` gained a
  "Verified against" column recording the exact tool version each analyzer
  integration was last exercised against, rather than leaving it implicit.
- A new "Component search" section explains what the index is, when it is
  built, and what the incomplete-search banner actually means.
- The Testing section documents `scripts/upload_sboms.sh` and corrects a stale
  corpus count (651 → 1,157 files / 1,015 distinct SBOMs).

## [1.6.24] - 2026-09-15

### Security

- **`rustls` 0.23.44 → 0.23.45 — fixes RUSTSEC-2026-0285**
  (GHSA-2mjx-qc3c-rqvc; CVSS v3.1 5.3 / v4.0 6.9, MEDIUM). rustls accepted
  TLS 1.3 handshake messages sent at the wrong encryption level when they
  followed a key-changing message in the same record, contrary to RFC 8446
  §5.1. The handshake transcript stays authenticated, so an attacker cannot
  alter or complete a handshake; the practical effect is confidentiality-only
  and LOW — a peer may send in plaintext handshake messages that should have
  been encrypted. This is not an incidental dependency: rustls is the TLS 1.3
  stack behind the HTTPS listener and behind the in-binary self-updater's
  transport, with eight immediate dependents in the 1.6.23 lockfile. Verified
  both directions — `cargo audit` over the 1.6.23 lockfile reports the
  advisory and exits 1; over the 1.6.24 lockfile it reports no
  vulnerabilities and exits 0. Published as ndaal-sa-2026-542.
- Disclosure timing, for the record: RUSTSEC-2026-0285 was published
  2026-09-14, six days after 1.6.23 shipped. 1.6.23 was therefore not
  knowingly released with a known-vulnerable dependency — the advisory did
  not exist when it was built and its own `cargo audit` gate passed at the
  time. Operators who build from source and cannot take this release yet can
  remediate in place with `cargo update -p rustls`; the fix is a
  semver-compatible patch and no manifest requirement has to change.

### Changed

- **`tantivy` 0.26.1 → 0.26.2** — routine patch bump of the embedded
  full-text search engine behind component search. No new transitive
  dependencies, no source change, and no change to the deliberately narrow
  feature selection. No known vulnerability at any version; recorded as
  ndaal-sa-2026-543 (CVSS 0.0, NONE).
- The `rustls` and `tantivy` manifest floors were raised to the versions
  above, so the minimum each is built against is the version that was
  actually tested.

### Deferred

- **`lo_writer` 0.5.6 was evaluated and deliberately NOT taken.** The upgrade
  (which also requires `lo_core` 0.5.5) is not self-contained: it pulls in
  five transitive crates the graph did not contain — `fontdb` 0.24.0,
  `fontconfig-parser` 0.5.8, `subsetter` 0.2.6, `roxmltree` 0.20.0 and
  `slotmap` 1.1.1 — growing the lockfile from 536 to 541. `roxmltree` 0.20.0
  is semver-incompatible with the 0.21.1 this crate already depends on
  directly, so the combination fails `cargo deny check` on the `bans`
  duplicate-version rule, which is green on either side of the bump alone.
  Since the upgrade carries no security fix (no RustSec advisory exists for
  `lo_writer`, `lo_core`, or any of the five new crates), it was dropped
  rather than papered over with a `deny.toml` skip. `lo_writer` stays at
  0.5.4. Revisit when the font stack converges on one `roxmltree` line.

## [1.6.23] - 2026-09-08

### Added

- **Rust `pre-commit` and `pre-push` Git hooks** under `.githooks/` (activate
  with `git config core.hooksPath .githooks`). Pre-commit auto-fixes then
  enforces rustfmt, `clippy -D warnings`, tests + doctests, a
  broken-intra-doc-link check, `typos`, and a rust-doctor 100/100 health gate;
  pre-push runs `cargo audit`, `cargo deny check`, `cargo semver-checks` and an
  MSRV build.

### Changed

- **The third vulnerability-enrichment source now defaults to a loopback
  vulnlookup** — `https://127.0.0.1/` on this host's own `8680` (TCP) / `8681`
  (QUIC) ports — rather than the public `https://vulnlookup.ndaal.eu` on 443.
  Sources 1 and 2 are unchanged. An operator running a co-located vulnlookup
  gets a working third fallback slot out of the box; everyone else overrides or
  blanks it under **Settings → Vulnerability enrichment**.
- **rust-doctor now gates at 100/100.** `rust-doctor.toml` was rewritten to the
  installed 0.7.0 schema (the previous config was silently rejected, so the gate
  never actually ran); every `#[allow]` now carries a `reason`, `clippy.toml`'s
  `disallowed-macros` are fully qualified (with a scoped `build.rs` exception for
  the cargo `println!` protocol), and the opinionated structural rules plus the
  six transitive duplicate-major-version conflicts are declined through a
  documented `[rules]` policy rather than by contorting cohesive code.

## [1.5.22] - 2026-09-07

### Added

- **Vulnerability enrichment with three real vulnlookup signals — EPSS, CISA-KEV
  and nuclei.** SBOM findings are now enriched from the vulnlookup service
  (default `https://vulnlookup.ndaal.eu`, port 443) with the EPSS exploitation
  probability and percentile, the CISA-KEV known-exploited flag, and whether a
  public nuclei exploit template exists (plus its severity). Findings gain **KEV
  / Nuclei / EPSS / Source** columns and are ordered most-urgent-first —
  KEV-known first, then EPSS percentile.
- **Settings → "Vulnerability enrichment": an on/off toggle plus up to three
  vulnlookup sources.** Each source now carries a source URL, a TCP port and a
  QUIC port (all default `443`; a blank port falls back to `443`). Sources are
  tried in order and the first to answer wins.
- **Enrichment is carried into every analysis export.** SARIF 2.1/2.2, Markdown,
  ODT, DOCX, PDF and CSAF 2.1/2.2 now include the EPSS, CISA-KEV and nuclei
  signals. CSAF emits EPSS as a native `metrics[].content.epss` metric and the
  CISA-KEV and nuclei signals as `notes`.

### Changed

- **The SSVC decision-point model was removed, replaced by the EPSS, CISA-KEV
  and nuclei signals above.** Its display, analysis-export and CSAF output are
  gone, together with the internal `ssvc` module (`src/ssvc.rs`) and the CSAF
  `SsvcV2` / `SsvcSelection` types (`src/csaf/model.rs`). The CSAF builder still
  emits the EPSS metric.
- **Every export format is now checked by default on the `/export` form**, so a
  one-click export produces all formats at once.
- **Enrichment cache rebuilt — storage schema v7.** The `v7` migration
  (`DDL_V7`) drops and recreates the `cve_enrichment` table with `epss`, `kev`
  and `nuclei` columns.

### Fixed

- **Enrichment was stored blank for every finding.** The vulnlookup response was
  parsed against the wrong shape — an SSVC / `epss.score` DTO that matched
  nothing under the endpoint's real `enrichments` object — so every finding
  recorded empty enrichment ("no enrichment with the uploaded SBOM"). The parser
  now reads the real `{"enrichments":{epss,cisa-kev,nuclei}}` shape.
- **Saving Settings with enrichment disabled or a blank port failed with "not a
  whole number".** Port parsing now treats a blank or absent port as `443`
  (previously a ranged parse rejected `""`), so disabling enrichment or clearing
  a port saves instead of returning 422.

## [1.4.21] - 2026-09-05

### Added

- **Stored SBOMs dashboard — search, filter, sort, pagination and a
  highest-severity column.** The home page (`/`) SBOM table gains:
  - **Full-text search** over each SBOM's name, format, spec version AND its
    contained component names/purls, backed by an in-memory `tantivy`
    (`0.26.1`) index rebuilt from the SQLite store at start-up and kept in sync
    on ingest/delete — so a query like `log4j` or `openssl` finds every SBOM
    that contains it. The query path is total: length-capped (1 KiB), parsed
    leniently, no regex queries, clamped result window, empty on any failure.
    The `tantivy` types stay behind `src/search.rs`.
  - **Filters** by format (CycloneDX JSON/XML, SPDX JSON, SPDX 3.0, SPDX
    tag-value, Syft JSON, Unknown) and by highest finding **severity**, with a
    **Reset** that clears filters but keeps the sort.
  - **Sortable** Name / Format / Components columns (whitelisted `ORDER BY` —
    a URL token never reaches SQL), with a direction indicator.
  - A **Severity column** (rightmost) showing each SBOM's single highest finding
    severity across its analyzer runs (a `MIN(rank)` correlated subquery; `—`
    when it has no findings yet), with the matching severity filter.
  - **Pagination at 25 rows/page** with a "Showing X–Y of N" caption and
    Previous / Page / Next controls that preserve the search, filters and sort.
  - New i18n keys for every control, translated across all 49 UI languages.
- **Example SBOM corpus tooling** under `example/` — `generate_sboms.sh`
  (generate an SBOM from a package DB), `fetch_redhat_sboms.sh` (download +
  verify Red Hat SPDX SBOMs), `fetch_k8s_sboms.sh` (download + verify
  Kubernetes SBOMs from `sbom.k8s.io`), and a canonicalized `download_sboms.sh`
  (collect SBOM assets from GitHub releases), each a skills/bash + skills/bats
  compliant script with a sibling `.bats` and README. The upload test suite
  gains an opt-in `--analyze` mode.

### Changed

- **Default SBOM upload size limit raised `50 MiB` → `100 MiB`**
  (`upload_max_bytes` in `src/store.rs`; `52_428_800` → `104_857_600`). The
  per-request body cap is the only real ceiling on an upload — there is no
  component-count limit. The Settings bound (1 GiB) and the Settings UI are
  unchanged. A deployment with a *persisted* `upload_max_bytes` keeps its
  stored value — change it in Settings for the new default to take effect.
- **HTML template attributes sorted to htmlhint's `attr-sorted` order**
  (`class` before `href`, …) across all 13 templates — 0 rendered `attr-sorted`
  findings (was 91). Pure reorder; Askama `{% if %}` conditional-attribute logic
  is preserved.
- **Dev-dependency `jsonschema` upgraded `0.51` → `0.53`** (test-only currency;
  not in the shipped binary). Advisory: `ndaal-sa-2026-494` (CVSS 0.0).

### Security

- **`RUSTSEC-2026-0253` accepted (ignored) in the audit policy** — `lru`
  (unsound, not a live CVE), pulled in transitively by `tantivy`. To be removed
  when `tantivy` drops the dependency.

### Held

- **`lo_writer` NOT bumped `0.5.4` → `0.5.6`.** The bump drags in `lo_core 0.5.5`,
  which hard-depends on font-discovery infrastructure the auditor does not use
  (it renders PDFs itself via `src/export_pdf.rs`): `fontdb`, `fontconfig-parser`
  (reads system fontconfig — unwanted ambient filesystem access for a
  cap-std-confined tool), `subsetter`, `memmap2`, `slotmap`, plus a duplicate
  `roxmltree 0.20` alongside our direct `0.21`. For a non-security patch-level
  currency bump that is unjustified dependency bloat, so `lo_writer` stays at
  `0.5.4` pending a decision to accept the font stack.

## [1.4.20] - 2026-09-03

### Added

- **Web UI in 49 languages — Chinese, Hindi and Urdu added (46 → 49).**
  Three Asian languages join the switcher, slotted alphabetical by English
  name: **Chinese (中文, Mandarin/Simplified)**, **Hindi (हिन्दी)** and
  **Urdu (اردو)**. English, Deutsch and Français keep their pinned front-of-menu
  order. The three additions are **machine-translated and pending native
  review** (each `src/i18n_data/<code>.tsv` says so in its header), with English
  fallback for any missing key; the switcher shows each by its native-name
  endonym.
- **Right-to-left rendering — Urdu is the first RTL language.** A new
  `Lang::dir()` returns `"rtl"` for Urdu and `"ltr"` for every other language,
  and the base layout now emits `<html lang="…" dir="…">`, so the whole document
  — navigation, tables, forms and the language menu — mirrors correctly for
  Urdu. Every other language stays left-to-right.
- Tests for the expanded set: an RTL invariant (`Ur.dir() == "rtl"`, all others
  `"ltr"` — kills a mutant that ignores direction), a round-trip over the three
  new codes including Chinese `zh-CN`/`zh-Hans`/`zh-TW` folding to `zh`, the
  compile-time `Lang::all().len() == 49` invariant, and the existing
  coverage-matrix / fuzz resolver now spanning all 49 languages.

### Changed

- **`self_update` bumped `1.2.0` → `1.3.0`** (non-security dependency currency).
  The new `proxy()` / `Error::InvalidProxy` are unused — the auditor injects its
  own aws-lc-rs / TLS-1.3-only hyper transport and never calls self_update's HTTP
  client, and `Error` is `#[non_exhaustive]` — and the `extract_file`
  leading-`./` fix is on a used path and non-breaking. The checksum-verified
  update contract is unchanged. Advisory: `ndaal-sa-2026-492` (CVSS 0.0).

### Note

- Localisation covers the **web UI only.** Generated reports (SARIF, CSAF and
  PDF exports) and the HTTP API remain **English-only.**

## [1.4.19] - 2026-09-03

### Changed

- **Dependency currency (non-security maintenance).** Two direct dependencies
  bumped after per-crate verification (confirmed latest from crates.io, changelog
  reviewed, RUSTSEC checked — no advisory affects any pinned version):
  - **`hyper` `1.11.0` → `1.11.1`** — four HTTP/1 + connection-pool behavioural
    fixes (caseless `TE: trailers` detection, `\n\r\n` head-terminator in the
    partial-read fast path, flush of buffered bytes before yielding, pooled-
    connection eviction on request-side `Connection: close`). None touch the API
    surface the auditor uses (`body::Incoming`, `service_fn`, `StatusCode`); the
    live-server Bruno/testssl suites cover the changed behaviour.
  - **`rcgen` `0.14.9` → `0.14.10`** — CRL/DER-encoding and ML-DSA (aws-lc-rs)
    fixes in code paths the auditor never calls (it generates ECDSA P-256
    self-signed certificates with SANs). Transitively advances `pem`
    `3.0.6` → `4.0.0` (a single lockfile entry, no duplicate version).
- **`self_update` bumped `1.0.0-rc.6` → `1.2.0`** (first stable of the 1.x
  line; upstream declares the API unchanged since rc.6, verified against our
  surfaces: the injected TLS-1.3 `HyperTransport`, `Checksum::Sha256`
  verification and the GitLab backend all compile and the 42 updater tests
  pass unchanged). Behavioural delta upstream: rate-limited responses now
  classify as `Error::RateLimited` instead of `Unauthorized` — we match on
  neither variant, so no code change. 1.1/1.2 additions (`verify_archive`,
  `checksum_from_asset`, `native-certs`) are opt-in and unused; adopting
  `checksum_from_asset("…SHA256SUMS.txt")` in place of our hand-rolled sums
  fetch is a candidate follow-up, not part of this bump.
- **Language menu laid out in columns of ten**: 43 entries were one unusably
  long single column on a laptop. On the expanded navbar (≥ lg) the menu is now
  a column-flow grid capped at 10 rows — five columns for 43 languages — via
  `.lang-menu.show` (matching the specificity of Bootstrap's
  `.dropdown-menu.show { display: block }`, which a bare class selector loses
  to). Below lg the collapsed burger menu keeps a single scrollable column.
  DOM order and arrow-key navigation are unchanged.
- **i18n re-architected** from the `(en, de, fr)` tuple/`match` tables to one
  embedded `key<TAB>text` TSV per language under `src/i18n_data/` with English
  fallback, so the set scales and a native reviewer can edit a single file.
  The public API (`t`, `Lang`, `code`/`endonym`/`from_code`/`all`, the
  cookie/`Accept-Language` resolver) is unchanged; the switcher and `/lang`
  route pick up the new languages automatically via `Lang::all()`.
- Corrected the README trio: the UI is 43 languages, not the five the note
  previously (incorrectly) claimed.

### Added

- **Web UI in 43 languages.** English, Deutsch and Français (the maintained,
  human-authored set) keep their historical order at the front of the language
  menu; every European official language (per `skills/languages-europe`:
  `national_official` + `official`) plus Northern Sami follows, alphabetical by
  English name. The 40 additions are **machine-translated and pending native
  review** (each `src/i18n_data/<code>.tsv` says so in its header), and any key
  a translation omits falls back to English (never a blank). It is picked from
  the in-page switcher (`/lang/{code}`), which sets the `sba_lang` cookie and
  shows each language by its native-name endonym.
- Tests for the expanded set: `fuzz_i18n_resolve` (the resolver stays total over
  attacker-controlled `Cookie` / `Accept-Language`; 155k runs clean), two Bruno
  requests (a European-addition cookie pin + the 43-language picker), a second
  Playwright journey (switch to a machine-translated language, assert
  `<html lang>` + cookie + a translated label), plus updated proptests and a
  compile-time `Lang::all().len() == 43` invariant.
- **Norway's official languages completed — 43 → 46 UI languages.** New:
  Norwegian Nynorsk (`nn`, Norway's co-equal official written standard beside
  Bokmål), Lule Sámi (`smj`) and Southern Sámi (`sma`) — the two sister Sámi
  languages holding the same regional-official status in Norway as the
  already-included Northern Sámi. The existing Norwegian entry is now labelled
  "Norsk bokmål", and `from_code` accepts **`nb` as a documented Bokmål alias
  of `no`** — previously a Bokmål-configured browser (which sends `nb` in
  Accept-Language) silently fell back to English. The alias is carried as an
  explicit exception through the round-trip proptest and the
  `fuzz_i18n_resolve` target. Both Sámi additions are very low-resource;
  their machine translations lean on adapted Scandinavian loans and are
  flagged pending native review like all non-maintained languages.

### Fixed

- **Language switching did nothing in a real browser** (any language, German
  included): `i18n::from_parts` read only the FIRST `cookie` header field, but
  HTTP/2 clients — Chrome included — split cookie pairs into SEPARATE `cookie`
  fields ("cookie crumbling", RFC 9113 §8.2.3), older cookies first. A
  developer browser on 127.0.0.1 carries cookies from every app ever run on
  that host (cookies ignore ports), so `sba_lang` arrived in a later field the
  server never saw and every switch silently fell back to `Accept-Language` /
  English. curl and single-cookie test contexts send ONE combined header,
  which is why every earlier check was falsely green. `from_parts` now joins
  ALL `cookie` fields; regression-proved by a unit test with split fields and
  a Playwright journey with a cookie-polluted context that failed on the old
  binary and passes on the fix (watched red → green).
- **The Bruno suite could never run from a fresh clone**: the root `.gitignore`
  blanket-ignored `bruno.json` and every `test/bruno/environments/*.bru`, so the
  collection manifest and the (secret-free, policy-mandated) `development.bru`
  were silently missing and `test/bruno/run.sh` hard-failed on the environment
  check. The ignore now excludes only `staging.bru` / `production.bru`, and the
  manifest + development environment (`base_url: https://127.0.0.1:8680`) are
  committed. Verified live: the i18n collection passes 4/4 requests, 7/7
  assertions.
- `09-example-corpus.spec.ts` still clicked the ambiguous
  `button[type="submit"]` in the export form, which matches BOTH "Export" and
  the newer "Start download" submit — a Playwright strict-mode violation.
  Disambiguated with `:not([formaction])`, the same fix `07-export.spec.ts`
  received when the second button shipped. Full Playwright suite is green
  (21/21), including both 43-language switcher journeys in real Chromium.

## [1.4.17] - 2026-08-30

### Fixed

- **Self-update checksum-mismatch (wrong asset selected).** `--self-update`
  failed with `ChecksumMismatch { expected … computed … }` because the
  `self_update` crate selects a release download by substring-matching the
  target triple, and the per-file checksum sidecars attached to each release
  (`…-<triple>.sha-256` / `.sha-512` / `.sha3-512` / `.blake3-512` /
  `.shake256-512`) contain the triple too — so it downloaded a 64-byte sidecar
  instead of the binary and the (correct) checksum verification rejected it.
  `release/create_release_on_gitlab.sh` now attaches **only the binaries + the
  `SHA256SUMS.txt` manifest** as release assets, so each triple matches exactly
  one asset (the binary); the per-file sidecars stay committed under `release/`
  and reachable raw-at-tag. The published v1.4.16 release's 30 sidecar links
  were removed too, so already-installed 1.4.14/1.4.15 binaries can self-update
  to 1.4.16. `src/updater.rs::asset_name` documents the constraint.
- **Examples-API test: trailing slash in `--base-url` broke every request.**
  `tests/scripts/test_examples_upload_via_api.sh --base-url https://host/`
  produced double-slash paths (`https://host//healthz`, `//upload`) that the
  matchit router 404s, so the preflight failed with "server not reachable".
  The base URL is now normalised (trailing slashes stripped), so
  `https://sbom.ndaal.eu/` works. Verified against the live instance.

### Changed

- Dev-dependency `jsonschema` upgraded `0.28` → `0.51` (test-only SARIF
  2.1.0 / 2.2 schema validation in `src/sarif.rs`; never in the binary).
  0.51 made `ValidationError::instance_path` a private field, so the
  test's error-rendering now calls the `instance_path()` accessor; a new
  `schema_errors_reports_a_violation_with_its_instance_path` adoption test
  proves the validator wiring actually detects a violation and names its
  instance path (the two pre-existing tests only asserted the empty case).
  MSRV 1.85 (< our 1.93); `default-features = false` retained (no
  `resolve-http` / `resolve-file` / `tls-aws-lc-rs`). The `csaf-rs` dev-dep
  keeps its own transitive `jsonschema 0.45`; both are test-only.

## [1.4.16] - 2026-08-25

### Added

- **Two more UI languages — Spanish and Italian.** The interface now
  ships in five languages (English, German, French, Spanish, Italian).
  `src/i18n.rs` gains the `Es` and `It` `Lang` variants and Spanish +
  Italian translations for every one of the 117 chrome keys; the language
  switcher and the `/lang/{code}` route pick them up automatically (the
  switcher iterates `Lang::all()`). Verified end-to-end: the i18n unit
  tests assert all five translations are present for every key, new Bruno
  cases (`test/bruno/collections/i18n/03-lang-es-cookie.bru`,
  `04-lang-it-cookie.bru`) check the `sba_lang` cookie for `es`/`it`, and
  the Playwright language spec exercises switching to Spanish and Italian
  in a real browser (3 passed). README + mirrors updated.

### Changed

- **htmlhint attribute-order cleanup.** Reordered attributes on 57 tags
  across 13 Askama templates to satisfy the `attr-sorted` rule (htmlhint
  findings dropped 105 → 48). Ordering only — no attribute value, Askama
  `{{ }}`/`{% %}` token, or rendering changed (the diff is a balanced 59/59;
  templates still compile via Askama and the Playwright render specs pass).
  The 48 remaining findings are structural false positives from linting
  Askama partials as full HTML documents — `{% if %}…{% endif %}`
  conditional attributes tokenized as pseudo-attributes, plus
  `doctype-first`/`h1-require`/`meta-*` on `{% extends %}` partials — and
  are left as-is (the repo's html gate is report-only for this reason).

## [1.4.15] - 2026-08-25

### Added

- **Cross-distro / cross-arch hardening validation matrix.**
  `scripts/validate_elf_hardening_matrix.sh` (with a sister `.bats`,
  20 tests) rebuilds a hardened musl binary in containers (Podman
  preferred, Docker fallback) across `debian13` (`debian:13`), `debian14`
  (`debian:testing`/forky) and `almalinux10` (`almalinux:10`) × `amd64`
  and `arm64` (arm64 via qemu emulation), runs the ELF gate against each
  final binary, and saves the validated binary + a `readelf` report +
  five checksum sidecars under `release/hardening-validation/<distro>-<arch>/`
  with a `SUMMARY.md` matrix. All six combos build static-PIE + Full
  RELRO + NX and pass the gate (the harness applies
  `-C relocation-model=pic -C link-arg=-static-pie` so aarch64 reaches
  static-PIE too). Documented as a new chapter in
  `skills/rust-hardening-elf`.

- **ELF hardening gate.** `tests/scripts/test_elf_hardening.sh` (with a
  sister `.bats`, 25 tests) verifies the Linux release binaries carry
  NX, PIE and RELRO by inspecting the FINAL ELF (readelf preferred,
  objdump fallback) rather than trusting build flags — per
  `skills/rust-hardening-elf`. It is fail-closed (executable stack,
  missing GNU_STACK, a non-exempt non-PIE binary, or Partial RELRO on a
  dynamic binary all fail), scopes to the current workspace version, and
  self-skips when no Linux binaries are built. Wired into
  `scripts/quality_gates.sh` as gate `8d` (`--only elf_hardening`).

### Security

- **Explicit ELF linker hardening for the musl targets.** A new
  `.cargo/config.toml` pins `-C relro-level=full` and
  `-Wl,-z,noexecstack` for `x86_64`/`aarch64-unknown-linux-musl`,
  guaranteeing a non-executable stack and full RELRO stay enforced even
  if a future native dependency would flip them. PIE is documented as a
  target-specific exception (`tests/scripts/elf_hardening_exceptions.txt`):
  the release binaries are fully-static musl, and rustc links crt-static
  musl as `ET_EXEC` — static-PIE is not produced by the cargo-zigbuild
  (zig cc) cross-linker (measured across four flag combinations), and
  gaining PIE would mean abandoning the fully-static portability design.
  The gate reports the gap as XFAIL rather than silently passing it.
  Validated on native Debian 13 (rustc 1.98) across a Vagrant VM, an
  amd64 Podman `debian:13` container, and an emulated arm64 one — the PIE
  gap is a zig-cross-linker artifact, and a native-Linux host closes it:
  `x86_64-musl` builds the real binary `static-pie linked` (ET_DYN, Full
  RELRO) by default, gate PASS; `aarch64-musl` is ET_EXEC by default but
  reaches static-PIE with an explicit `-static-pie` link-arg. The gate was
  exercised on both x86_64 and aarch64 binaries.
- **ELF gate readelf/objdump parity fix.** `test_elf_hardening.sh` now
  decodes BIND_NOW from objdump's numeric `DT_FLAGS`/`DT_FLAGS_1` bits, not
  only readelf's symbolic form — without it a static-PIE binary read as Full
  RELRO under readelf (Linux) but Partial under objdump (macOS). Covered by a
  new bats regression test (26 total).

### Changed

- **Dependency currency bump.** Routine patch/minor refresh of eleven
  crates, all within existing semver constraints, verified green
  (fmt, clippy `-D warnings`, 465 tests, `cargo audit` clean — no
  advisory applies to any of them): `blake3` 1.8.5 → 1.8.7,
  `cap-std`/`cap-primitives` 4.0.2 → 4.0.3, `h2` 0.4.18 → 0.4.19,
  `http-body-util` 0.1.4 → 0.1.5, `libsqlite3-sys` 0.38.1 → 0.38.2,
  `lo_math`/`lo_odf` 0.5.2 → 0.5.3, `lo_writer` 0.5.2 → 0.5.4,
  `rcgen` 0.14.8 → 0.14.9, `rusqlite` 0.40.1 → 0.40.2.

## [1.4.14] - 2026-08-23

### Added

- **Examples-upload API test.**
  `tests/scripts/test_examples_upload_via_api.sh` (with a sister
  `.bats`) uploads every `example/` SBOM against a running instance
  and asserts each yields a SARIF, a CSAF and a PDF, pulled purely
  over the API through the streamed `POST /export/download` ZIP. TLS
  is verified for a non-loopback host and the self-signed cert is
  skipped for `localhost`; `--base-url` targets any instance, e.g.
  `https://sbom.ndaal.eu/`.

### Fixed

- **Quality-gate runners resolved the workspace one directory too
  high.** Every `tests/scripts/*.sh` runner was ported from the peer
  `vulnerability-lookup-rs` repo, where the crate lived under a
  `vulnerability-lookup-rs/` sub-tree, so `REPO_ROOT` used a
  three-level `../../..` and `QG_WORKSPACE` defaulted to
  `${REPO_ROOT}/vulnerability-lookup-rs`. In this single-crate repo
  the scripts sit at `<repo>/tests/scripts/`, so both pointed one
  level too high — writing report archives outside the repo tree and
  creating a bogus sibling directory. `REPO_ROOT` now resolves to the
  repo root and the `QG_WORKSPACE` default drops the non-existent
  sub-tree, so the gates read and write in-tree with no stray
  side-writes.

## [1.4.13] - 2026-08-21

### Security

- **`h2` unbounded empty DATA frames (RUSTSEC-2026-0258).** Bumped the
  transitive `h2` HTTP/2 dependency from 0.4.15 to 0.4.18 so a remote
  peer can no longer queue empty DATA frames without bound;
  `cargo audit` is clean afterwards. Tracked as `ndaal-sa-2026-441`.
- **Operator TLS key hardening (#17).** `--tls-key` is now refused on
  Unix when the key file is group/other-accessible (CWE-732), and a
  PKCS#1 RSA key is refused by the no-RSA crypto policy. Tracked as
  `ndaal-sa-2026-440`.
- **Export download extension gate (#16).** `GET /export/download`
  serves only files whose name carries a recognised export or
  checksum-sidecar extension, so a misconfigured export directory
  cannot hand out unrelated files such as `passwd` or a private key.
- Earlier fixes shipped in this release: `ndaal-sa-2026-437`
  (multipart CPU-exhaustion DoS), `-438` (silent false all-clear on
  undecryptable at-rest data), and `-439` (unvalidated CVSS in
  generated advisories).

### Added

- **Streamed "Start download" export.** A new `POST /export/download`
  route renders the selected formats in memory and streams the result
  to the browser as a file attachment, writing nothing to the
  server-side export directory. It coexists with the existing
  `GET /export/download?file=<name>` per-file link; the router
  dispatches by method. A single selected artifact streams as that one
  document; several selections — or a CSAF selection, which fans out to
  multiple files — are packed into one in-memory ZIP bundle via the
  `lo_zip` crate. The route is gated by the same-origin CSRF check
  (403 on cross-origin); an empty selection returns 422 and an unknown
  SBOM returns 404. Implemented as `export::build_download` /
  `DownloadArtifact` in `src/export.rs` and `export_download_stream` /
  `stream_download_request` in `src/routes/export.rs`. The export form
  now shows two submit buttons: the primary "Export"
  (`POST /export/run`, which persists files plus checksum sidecars on
  the server, as before) and "Start download"
  (`formaction="/export/download"`, which streams and persists
  nothing), plus help text explaining the contrast.
- **AI-driven Magnitude visual tests.** A new `test/magnitude/` suite
  (Magnitude framework, `*.mag.ts`) covers the navbar order and
  completeness and the export page defaults plus "Start download". They
  complement — and do not replace — the deterministic Playwright
  (`test/playwright`), Bruno (`test/bruno`), and Rust integration
  tests. They need an LLM to run and are not part of the blocking CI
  gate. New dependency: `lo_zip` (shares `lo_writer`'s `lo_core`, so no
  new heavy dependency tree).
- **Audit-driven test coverage.** A `sbom_component::parse` cargo-fuzz
  target (the primary upload/import parser, seeded from the SBOM
  fixtures), CVSS-extractor property tests, a structured bolero harness
  for the component walkers, and regression tests for the
  `/components/reindex` route, the export download handler, the updater
  checksum-manifest wiring, exhaustive i18n key resolution, and a
  populated-database migration across the v4 `ADD COLUMN` rung.

### Changed

- **Top-nav order.** The left-hand nav is now Home, Upload, Import,
  Export, Components, Tools, Settings, followed by the right-hand
  controls: a Dark/Light theme switch, a Language menu, and an Info
  menu.
- **Export format defaults.** On the export form the four
  machine-readable standards — SARIF 2.1, SARIF 2.2, CSAF 2.1, and
  CSAF 2.2 — are now ticked by default, while the four document formats
  (Markdown, ODT, DOCX, PDF) are not. There are eight export formats in
  total.
- **Export label "SARIF 2.1.0" renamed to "SARIF 2.1".** Only the
  checkbox label changed; the SARIF document version inside the file
  and the artifact filename (`…-v2.1.0.sarif`) are unchanged.

## [1.4.11] - 2026-08-11

First 1.x release.

### Added

- **The test harnesses now cover the 0.4.10 surfaces.** `/components` and
  `/sbom/{id}/scorecard` shipped in 0.4.10 with unit and route tests but
  no presence in the out-of-process harnesses, so nothing exercised them
  end to end. Added across all five: two Bruno requests for the scorecard
  (including the 404 path), one for `/components`, and one asserting
  `/about` names all nine analyzers; two Playwright spec files — the
  scorecard spec seeds its own SBOM rather than borrowing one another
  file left behind, which would pass or skip depending on worker
  scheduling; two Goose journey steps, where the component query is
  measured separately from the empty page because it is the only read in
  the mix that `LIKE`-scans `sbom_components` and averaging the two would
  hide how that cost grows with the index; and four scorecard properties
  (`proptest`) pinning that arbitrary bytes always yield exactly
  seventeen elements in the 9/8 split, that `met()` can never exceed what
  was scored met, that zero components can never score a component
  element met, and that every status renders with both a label and a
  colour class.
- **A fifth Loom model** —
  `component_index_rebuild_is_never_observed_half_done`.
  `Store::replace_components` rebuilds an SBOM's component index as
  DELETE-then-INSERT while holding one mutex guard across the whole
  transaction. The model proves, over every interleaving, that a
  concurrent `search_components` sees the old index or the new one and
  never the empty window between them: on a page that answers "which of
  my SBOMs declare this component", a momentary zero reads as *not
  affected* — a false negative with no error and nothing in the log.

### Fixed

- **The release pipeline's preflight had never actually run for this
  repo.** `release_pipeline.sh` stage 1 invokes the shared
  `scripts/quality_gates.sh`, whose `QG_WORKSPACE` defaults to
  `./vulnerability-lookup-rs` — a sibling project. Here the workspace IS
  the repo root, so the stage aborted rc=2 "workspace not found", and the
  only way past it was `RP_SKIP_PREFLIGHT=1`; 0.4.10 shipped that way,
  with a pipeline that reported clean while its preflight had been
  skipped. The pipeline gains `--workspace PATH` (env `RP_WORKSPACE`,
  default: the repo root), exported to the stage as `QG_WORKSPACE`. A
  path with no `Cargo.toml` is flagged in the `--dry-run` plan and aborts
  a real run before the cross-compile burns; `--workspace` with no value
  is an error rather than a silent fallback. New sibling
  `release/release_pipeline.bats` (20 tests) — the first bats coverage
  for anything under `release/`, and distinct from
  `scripts/release_pipeline.bats`, which tests a different script that
  shares the basename.
- **The workspace Clippy gate was red, and had been since 0.4.10.** Rust
  1.97 tightened several lints and the 0.4.10 modules tripped eighteen of
  them: `derive_partial_eq_without_eq` across the CSAF model and the
  scorecard types, `option_if_let_else` in `product_key` and
  `csaf_artifact_name`, `missing_const_for_fn` on `Scorecard::total`,
  `too_long_first_doc_paragraph` in a findings test, and
  `significant_drop_tightening` on the three new store methods. The store
  fix follows the pattern already established in that file — the bodies
  move to free functions taking `&Connection`, so the guard is a
  temporary that drops at the end of one statement. `replace_components`
  keeps its guard for the whole transaction deliberately, which is
  exactly what the new Loom model proves must happen. The standalone
  `loadtest/` crate tripped the same tightening
  (`needless_question_mark` on both scenario builders) and is clean
  again.
- **The Playwright analyze specs waited on a signal 0.4.10 removed.**
  Both polled for `hx-get` to *disappear* from the run-status fragment as
  the "analysis finished" signal. The 0.4.10 raw-HTML-lint fix made that
  attribute unconditional — a conditional wrapped around an `attr="value"`
  pair is unparseable as a tag — and moved the decision to the sibling
  `hx-trigger`, which flips to `none`. So the assertion could no longer
  ever become true and both specs ran to their deadline whatever the
  server did. They now assert `hx-trigger="none"`. The budgets are also
  raised (8-minute poll inside a 10-minute per-test ceiling): the ensemble
  went from five tools to nine in 0.3.8, and a cold run additionally pays
  for grype's one-shot vulnerability-DB update, which alone can reach its
  own five-minute deadline.
- **`/about` under-reported the analyzer ensemble.** The page hand-listed
  five tools while the ensemble has been nine since 0.3.8 — bomdrift,
  inspektr_cli, shieldbom and provenant were missing, so the page told a
  reader the auditor runs less than it does. It now renders from the
  canonical `Tool::all()`, the same source `/settings` derives from, so
  the two cannot diverge by construction. A route test pins it: every
  analyzer `/settings` can toggle must be described on `/about`, with
  `/settings` treated as canonical because that is where an operator
  enables the ensemble.
- **TLP labels lost their colours.** The CSAF publisher settings rendered
  the TLP label as a plain dropdown, so RED, AMBER, GREEN and CLEAR were
  visually indistinguishable — a poor affordance for a disclosure
  marking, where getting it wrong over-shares. They are now colour-coded
  radio badges following the FIRST TLP 2.0 convention, with the label
  text rendered beside every badge so colour is never the only signal.

## [0.4.10] - 2026-08-06

### Added

- **Component-level SBOM parsing** (`src/sbom_component/`). Until now the
  auditor read three things out of an SBOM — format, spec version and a
  component count — and handed the bytes to subprocesses as an opaque blob.
  It now lifts, per component: name, version, producer, package URLs, CPEs,
  checksums and licences; the dependency edges between components; and the
  document's own metadata (authors, generating tools, timestamp, serial
  number, lifecycle phase, signature presence). All six supported formats
  are covered — `CycloneDX` JSON and XML (recursing into nested
  components), SPDX 2.x JSON and tag:value, SPDX 3.0 JSON-LD, syft JSON.
  `NOASSERTION` is preserved verbatim rather than folded into "absent",
  because "the producer knows it does not know" is different information.
- **CISA 2026 minimum-elements scorecard** (`src/scorecard.rs`, page at
  `/sbom/{id}/scorecard`). Scores an ingested SBOM against all seventeen
  elements — nine SBOM-metadata, eight component-data — answering whether
  a supplier provided what the guidance asks for, which no vulnerability
  scan can. A component element is *met* only when EVERY component carries
  it; an SBOM that is 99% complete is not complete. Also warns on
  deprecated format versions (`CycloneDX` <= 1.2, SPDX 2.1/2.2).
- **Cross-SBOM component search** (`/components`, schema v5). Answers
  "which of my SBOMs ship log4j-core", which no per-document view can.
  Components are indexed at ingest; the page counts SBOMs with no index
  yet and offers a rebuild rather than presenting a partial index as a
  complete answer — in a supply-chain search, "not found" reads as "not
  affected".
- **CSAF 2.1 / 2.2 advisory export** (`src/csaf/`, two new export
  formats). Turns one SBOM's analysis into an OASIS CSAF advisory, with
  findings merged by CVE across tools. Skipped, failed and timed-out
  analyzers become an explicit "Analysis coverage" note naming each tool
  and its reason. Optional per-finding burst via the new
  `csaf_split_per_finding` setting.
- **CVSS vector capture** (`src/cvss.rs`, schema v4). Six of the seven
  finding parsers previously hardcoded no score at all. Score, vector and
  version are now lifted from each tool's real wire shape. A CVSS 4.0
  vector is never derived from a 3.1 one — they are different metric sets
  — and a vector with no numeric base score (OSV) yields no CSAF metric
  rather than a fabricated number.
- New settings: `csaf_publisher_name`, `csaf_publisher_namespace`,
  `csaf_publisher_contact`, `csaf_tlp`, `csaf_split_per_finding`.

### Fixed

- **Migration ladder**: rung 3 stamped `PRAGMA user_version =
  {SCHEMA_VERSION}` interpolated, so bumping `SCHEMA_VERSION` would have
  made rung 3 stamp the new version and silently skip every later rung —
  the new columns would never have been created on an existing database.
  Each rung now stamps its own literal.
- **Raw-HTML lint**: three templates wrapped a conditional around an
  attribute with no separating space, which htmlhint reads as one
  malformed tag. Askama rendered them correctly, so only the lint was red
  — but a permanently-red lint is a lint nobody reads. All 21 templates
  now pass.
- Two pre-existing doc-lint errors in `benches/parsers.rs` that were
  failing `clippy --workspace --all-targets` independently.

### Changed

- The export surface grows from six formats to eight. CSAF is the first
  format that can emit more than one artifact, so rendering now returns a
  list; the other six are unchanged.

### Documentation

- **CISA 2026 SBOM Minimum Elements — conformance assessment** at
  [`CSI_2026_cisa_sbom_minimum_elements_508c.md`](documentation/compliance/CSI_2026_cisa_sbom_minimum_elements_508c.md).
  Assesses `sbom-auditor` 0.3.9 against the CISA publication of
  29 July 2026 (17 data fields, 6 practices and processes), co-authored
  with 17 international organizations including the BSI. Result: 2 of 9
  SBOM metadata fields met, 0 of 8 component data fields, and 1 met /
  3 partial / 2 gap across the practices. The assessment records the
  single root cause — the auditor treats an SBOM as an opaque blob to
  hand to the analyzer ensemble rather than parsing it — alongside the
  eight areas where the tool exceeds the guidance (false-green guard,
  machine-readable non-coverage, five checksum families over four
  constructions, offline operation, dual schema-validated SARIF,
  SPDX 3.0 acceptance), and a ranked gap list `G1`–`G12`. A
  component-level SBOM parser (`G1`) and a minimum-elements scorecard
  (`G2`) are identified as the highest-leverage work: `G1` alone
  converts 15 of the 17 data-field rows.
- The assessment's verdict tables are refreshed for this release: with the
  parser, scorecard and CSAF export in place the auditor now reads 16 of
  the 17 data fields (the seventeenth, SBOM Author Signature, is detected
  but not cryptographically verified) and closes gaps `G1`, `G2`, `G3`
  and `G10`.

## [0.3.9] - 2026-08-05

### Changed

- Version bump to 0.3.9; development continues on top of the 0.3.8 release.
- **Dependency currency:** `base64` 0.23.0 → 0.23.1 (semver-compatible patch;
  the at-rest crypto path in `src/crypto.rs` encodes sealed TEXT-column values
  with it). No source change — `Cargo.toml`'s `base64 = "0.23"` already
  permitted it. Recorded as CSAF advisory `ndaal-sa-2026-379` (CVSS 0.0, NONE).

### Fixed

- **Release pipeline** (`release/create_release_on_gitlab.sh`): push the current
  branch via an explicit `<branch>:<branch>` refspec instead of `HEAD:main`
  (avoids the non-fast-forward rejection and this repo's `.invalid`-HEAD
  resolution), and upload GitLab release assets individually — tolerating
  "already attached" — so a partial upload is idempotent and self-heals on
  re-run rather than aborting on the first collision.

### Documentation

- CSAF advisory `ndaal-sa-2026-380` announces the 0.3.8 release (informational,
  reproducing the 0.3.8 changelog).

### Testing

- Six additional `skills/rust-testing` sequences over the pure-domain parsers,
  each verified green before commit:
  - **bolero + arbitrary** structured fuzzing (`tests/bolero_structured.rs`) —
    three harnesses over `sbom_format::detect`, `findings::parse`,
    `licensing::parse`.
  - **Kani** bit-precise proofs (`kani-harness/`, standalone crate mirroring
    `loom-harness/` so Kani's pinned nightly need not build `libsqlite3-sys`):
    `Severity::from_cvss` matches the documented CVSS bands and is monotonic —
    both `VERIFICATION:- SUCCESSFUL`.
  - **Criterion** parser benchmarks (`benches/parsers.rs`).
  - **cargo-insta** golden snapshots (`src/snapshots/`) pinning the Markdown and
    SARIF 2.1 / 2.2 export shapes, with the `.snap.new` drift gate.
  - **assert_cmd** process-contract tests (`tests/cli_contract.rs`) — exit
    status + stdout/stderr for `--version` / `--help` and rejected invocations.
  - **cucumber** BDD acceptance loop (`tests/bdd.rs`,
    `tests/features/sbom_analysis.feature`) — four Gherkin scenarios,
    `fail_on_skipped()` so no scenario passes vacuously.

## [0.3.8] - 2026-08-05

### Added

- **Four new analyzers, bringing the ensemble to nine.** Each is optional at
  runtime, detected on `PATH`, overridable via `SBA_TOOL_<NAME>_BIN`, and
  self-skips (never fake-green) when absent:
  - **bomdrift** ([crates.io](https://crates.io/crates/bomdrift)) — supply-chain
    drift / typosquat detection. Diffs the SBOM against an empty baseline the
    orchestrator writes to scratch and normalises the SARIF result.
  - **inspektr** ([crates.io](https://crates.io/crates/inspektr)) — SBOM
    vulnerability scanner; its top-level `vulnerabilities` JSON is normalised
    into CVE-shaped findings.
  - **shieldbom** ([crates.io](https://crates.io/crates/shieldbom-core)) —
    offline-first embedded / IoT CVE scanner; SARIF 2.1.0 output (the CVE is
    the `ruleId`, the CVSS-like `security-severity` property drives severity).
  - **provenant** ([crates.io](https://crates.io/crates/provenant-cli)) — a
    ScanCode Toolkit port reporting licence / copyright / provenance rather than
    vulnerabilities.
- **Licence & provenance findings.** provenant's output is not CVE-shaped, so it
  gets its own model (`LicenseFinding`: licence / copyright / holder / author),
  its own store table (additive schema v3, `license_findings`, foreign-keyed to
  the run with `ON DELETE CASCADE`), a "Licence & provenance" section on the
  SBOM detail page (En/De/Fr), and a per-tool section in the Markdown export —
  so provenance data is surfaced instead of discarded.
- **SARIF 2.1.0 finding parser** shared by bomdrift and shieldbom: GitHub's
  `security-severity` property drives severity when present, otherwise the SARIF
  `level` maps (error → high, warning → medium, note → low).

### Testing

- Compile-time invariants (`static_assertions`) for the new types; the
  nine-tool ensemble count is pinned.
- Property tests: both untrusted-output parsers never panic on arbitrary bytes
  and only ever emit store-valid rows; SARIF and ScanCode output are bounded by
  their declared item count (no amplification).
- cargo-fuzz: the findings target now covers the three new sources; a new
  `fuzz_licensing_parse` target covers provenant, each seeded with a valid
  sample (both smoke-ran clean).
- End-to-end integration tests (stub tools) prove each output shape is
  persisted through the whole orchestration, and that provenant lands licence
  findings in their own table and nothing in the vulnerability table.

## [0.3.7] - 2026-08-04

### Fixed

- **grype vulnerability-DB warm-up.** grype's scan runs offline by design
  (`GRYPE_DB_AUTO_UPDATE=false`), so once its DB passed grype's built-in max
  age (5 days) grype hard-failed — surfacing a routine DB refresh as a
  `fail`. The analyzer now runs `grype db update` at most **once per
  process**, out of band, before the first grype scan; the scan itself stays
  offline against the freshened DB. The warm-up is best-effort: on a
  genuinely offline host it fails fast and grype scans against whatever DB it
  has. Verified: with the DB refreshed, all five analyzers (syft / grype /
  trivy / osv-scanner / bomber) now complete `ok` on the CycloneDX log4j
  fixture (grype: 20 findings), where grype previously recorded `fail`.

## [0.3.6] - 2026-08-04

### Fixed

- **osv-scanner invocation modernised.** The analyzer called `osv-scanner
  scan source --sbom <input>`; `--sbom` is deprecated in osv-scanner 2.x and
  `scan source` is a *directory* walker (it logged `Starting filesystem walk
  for root: /` and exited 128 with "could not determine extractor"). The call
  is now `osv-scanner scan --lockfile <input> --format json`, which reads the
  single materialised file and auto-detects its format from the
  server-controlled name (`input.cdx.json`, `input.spdx.json`, the converted
  `scanner-input.cdx.json`, …). Verified: osv-scanner now completes `ok` with
  32 findings on the CycloneDX log4j fixture where it previously failed.

## [0.3.5] - 2026-08-04

### Changed

- **SPDX 3.0 is normalised for the scanners via `syft convert`.** The
  vulnerability scanners (grype / trivy / osv-scanner / bomber) cannot read
  SPDX 3.0 JSON-LD. The analyzer now converts an SPDX 3.0 input to CycloneDX
  **once, up front**, and every scanner reads the conversion; syft itself
  still reads the original. When syft cannot produce the conversion (it is
  not installed), the scanners **skip** with a clear reason instead of
  failing on an input they cannot parse. This supersedes the 0.3.4 note that
  the scanners fail on SPDX 3.0; with syft present, trivy now scans an SPDX
  3.0 SBOM that previously failed.

### Notes

- Vulnerability matching after conversion depends on the SBOM carrying
  package URLs. The upstream SPDX 3.0 examples describe source files and AI
  datasets whose components have no `purl`, so purl-strict scanners
  (grype / osv-scanner / bomber) still find nothing to match on those
  fixtures; a package-manager SPDX 3.0 SBOM (with purls) matches normally.
  This is honest tool behaviour, not a conversion failure — trivy parses the
  converted CycloneDX successfully either way.

## [0.3.4] - 2026-08-04

### Added

- **SPDX 3.0 JSON-LD ingest.** SBOM files in the SPDX 3.0 JSON-LD encoding
  (`@context` + `@graph`, e.g. `*.spdx3.json`) are now detected, counted and
  stored instead of being rejected with `422 Unprocessable Entity`. A new
  `SbomFormat::Spdx3Json` (`spdx3-json`) variant is detected from the SPDX
  3.x `@context` (or a graph `specVersion` beginning `3.`); the spec version
  is read from the graph's `CreationInfo` and the component count is the
  number of package elements (`software_Package` and its profile subclasses
  `ai_AIPackage` / `dataset_DatasetPackage`, all ending in `Package`).

### Changed

- **Store schema v2.** The `sboms.format` CHECK constraint is broadened to
  allow `spdx3-json`. Because `SQLite` cannot alter a CHECK in place, the
  `migrate` ladder gains a v2 rung that rebuilds the `sboms` table with
  foreign keys disabled, so the rebuild does not cascade-delete existing
  `analysis_runs`; an existing v1 database upgrades in place with its data
  intact.

### Notes

- The external scanners handle an SPDX 3.0 input per their own capabilities:
  `syft` parses it; scanner versions that do not yet support SPDX 3.0 fail
  cleanly (a visible `fail`, never a false `ok`), and reports are still
  produced documenting the outcome. Ingest, detection, storage and export do
  not depend on the scanners.

## [0.3.3] - 2026-08-04

Test-fixture release. No change to application behaviour, and no change to
the published crate: the Cargo `include` list scopes the crates.io package
to the crate's own sources, so the example corpus is not part of the
published package.

### Added

- `example/` — a corpus of 651 public test SBOM files for exercising
  ingest, format detection, the analyzer ensemble and the exporters,
  fetched unmodified from four upstream collections:
  anchore/sbom-examples (510 files, Apache-2.0), CycloneDX/bom-examples
  (83, CC0-1.0), spdx/spdx-examples (56, GPL-3.0) and
  sonatype-nexus-community/cyclonedx-sbom-examples (2, Apache-2.0). The
  SBOM data files are tracked via Git LFS (~2.6 GB); `PROVENANCE.md` and a
  per-file `MANIFEST.csv` ship alongside in plain git. See
  `example/PROVENANCE.md`.
- End-to-end test sequences that drive the corpus through the API and the
  browser UI (upload → analyze → export) and assert that reports are
  produced.

## [0.2.1] - 2026-08-03

Packaging and distribution release. No changes to application behaviour;
this version makes the auditor installable as a prebuilt binary.

### Added

- `cargo binstall` support: `[package.metadata.binstall]` points at the
  GitLab release assets, matching the self-updater's raw-binary naming
  (`sbom-auditor-<version>-<triple>`), so `cargo binstall sbom-auditor`
  fetches a prebuilt binary instead of compiling from source.
- Prebuilt release binaries for the supported target triples, each with
  the five cryptographic hash sidecars (`.sha-256`, `.sha-512`,
  `.sha3-512`, `.blake3-512`, `.shake256-512`).

### Changed

- Scoped the published crate with a Cargo `include` list so the package
  contains only the crate itself (sources, embedded assets, build
  script, licence and the info-menu docs) rather than the whole
  repository. The published package is ~832 KiB.
- Updated dependencies to their latest releases: `cap-std` 3.4 → 4.0
  (with `cap-primitives` 4.0 and `io-extras` 0.19 — the
  capability-confined filesystem; the used `Dir` API is unchanged across
  the major, so no code change), `base64` 0.22 → 0.23, `roxmltree`
  0.20 → 0.21, and `time` 0.3.54 → 0.3.55. None of these updates
  remediate a security advisory (`cargo audit` / RUSTSEC report no
  applicable vulnerability for the previous versions); they are routine
  supply-chain currency updates. The dev-only `jsonschema` is
  intentionally held at 0.28.

## [0.1.0] - 2026-08-03

The initial build. A single offline binary that ingests SBOM files,
analyses them with an ensemble of external tools, normalises the
findings and exports them in six formats.

### Added

#### Secure web shell (transport and hardening)

- HTTPS-only listener: TLS 1.3 via rustls (aws-lc-rs provider with
  `prefer-post-quantum`, so the X25519MLKEM768 post-quantum hybrid
  key-exchange group is offered first); self-signed certificate
  regenerated on every start-up, or an operator-supplied
  `--tls-cert` / `--tls-key` pair.
- Loopback-only bind by default (`127.0.0.1:8680`), with an explicit
  `--allow-non-loopback` opt-in and an anti-DNS-rebinding request-Host
  allowlist (`--allowed-host` / `SBA_ALLOWED_HOSTS`).
- Security-header middleware on every response: HSTS, `X-Frame-Options`
  DENY, a hash-pinned Content-Security-Policy, `X-Content-Type-Options`
  nosniff, referrer minimisation, a Permissions-Policy deny list and
  cross-origin isolation headers.
- Same-origin check on every state-changing POST and a request-body
  size limit.
- Hand-rolled hyper 1 + matchit router, shared by the binary and the
  integration tests so both exercise the identical route table.
- Connection admission control (a semaphore cap), TLS-handshake,
  header-read and whole-request timeouts, and graceful shutdown.
- Embedded static assets (Bootstrap, Bootstrap Icons, htmx, Roboto and
  Roboto Mono fonts, logo) — no CDN, no filesystem reads.

#### Persistent store, settings and storage encryption

- Bundled SQLite store (rusqlite with a statically linked, vendored
  SQLite) in WAL mode, holding the SBOM inventory, the analyzer run
  ledger, findings, operator settings and an append-only audit log; a
  `PRAGMA user_version` migration ladder versions the schema.
- Typed operator settings persisted in the database (import and export
  directories, per-run analyzer timeout, analyzer concurrency, the five
  checksum-sidecar toggles, the five analyzer enable toggles and the
  upload size limit) with a settings page and an audited change diff.
- Optional storage encryption (XChaCha20-Poly1305) that seals stored
  SBOM bytes and raw analyzer output while they sit on disk, keyed by
  `--encryption-key-file`; pre-existing plaintext rows still read back.
- Capability-confined filesystem: every data, import and export path is
  opened through a cap-std handle, closing path-traversal, symlink and
  TOCTOU classes at the syscall layer. Stored file names are
  server-derived.

#### SBOM ingest

- Browser upload (`POST /upload`) via a hand-rolled, size-limited
  single-file multipart parser, and bulk import from a configured folder
  (`POST /import`) via a cap-std directory walk.
- Format detection for CycloneDX (JSON/XML), SPDX (JSON/tag-value) and
  syft-json, with the declared spec version and component count captured
  at ingest.
- Content-hash (SHA-256) deduplication: re-ingesting the same bytes
  returns the existing entry rather than a duplicate row.

#### Analyzer orchestration

- An ensemble of five optional, `PATH`-detected tools — syft, grype,
  trivy, osv-scanner and bomber — each overridable with
  `SBA_TOOL_<NAME>_BIN` and individually enableable in settings.
- Each tool runs as an isolated subprocess under a per-run deadline, a
  process-wide concurrency semaphore, bounded stdout/stderr capture and
  `kill_on_drop` on timeout.
- A false-green guard: a run is `ok` only when the tool ran and produced
  parseable JSON (a non-zero exit on findings still counts); a missing
  binary is a visibly distinct `skip`, unparseable output is `fail`, and
  an elapsed deadline is `timeout`. A skipped tool is never shown as
  success.
- The bomber compatibility gate normalises CycloneDX newer than 1.6 via
  `syft convert`, and skips with a documented reason when syft cannot
  convert the input.
- The stored SBOM is decrypted into a per-analysis scratch directory
  (removed on completion) so a tool is never handed the sealed on-disk
  file; captured output is sealed again on disk.
- The detail page polls run status over htmx until every run is
  terminal.

#### Findings normalization and detail UI

- Each `ok` run's JSON is normalised into a common finding shape per
  tool, with the CVE alias resolved from the tool's GHSA/OSV identifier
  and a normalised severity.
- Findings are shown per tool with severity badges, ordered most severe
  first; a live `/tools` inventory page probes each tool's presence,
  version, role and documented caveat.

#### Multi-format export with checksum sidecars

- Six output formats produced in-process by native Rust crates (no
  pandoc, typst or LibreOffice shell-out): SARIF 2.1.0, SARIF 2.2,
  Markdown, ODT, DOCX and PDF (the PDF embeds Roboto for umlaut-safe
  output).
- Every export lists skipped and failed analyzers with their reasons, so
  an incomplete analysis is never presented as clean.
- Up to five checksum sidecars per artifact (`.sha-256`, `.sha-512`,
  `.sha3-512`, `.blake3-512`, `.shake256-512`), each toggleable in
  settings, written into the capability-scoped export directory; the
  download route allowlists the served name against the directory
  listing.

#### Internationalization and operability

- Trilingual UI chrome (English / German / French) with a cookie-pinned
  language switcher (`sba_lang`) and a dark-mode toggle (`sba-theme`).
- Info menu with dialog pages: About, System info, Privacy, Security,
  License, Imprint, Changelog, README, Administrator Guide and User
  Guide.
- Liveness (`/healthz`) and readiness (`/readyz`) probes.
- Structured logging (`--log-format json`) plus an append-only log file
  under `--log-dir` (default `./ndaal/log`).
- Hardened self-update (`--check-update` / `--self-update`) over the
  TLS 1.3 stack, verifying the download against the published checksums
  and refusing on mismatch, with a `--no-self-update` policy opt-out.

#### Test harnesses

- Rust unit and integration suite (`cargo nextest run`) plus doctests.
- `loom-harness/` concurrency-permutation models of the shared state.
- `test/bruno/` API collection exercising every route against a live
  server.
- `test/playwright/` browser E2E of the upload, analyze, findings and
  export journeys.
- `loadtest/` Goose scripted-scenario load test.

[0.3.7]: https://gitlab.com/vPierre/ndaal_public_sbom_auditor/-/releases/v0.3.7
[0.2.1]: https://gitlab.com/vPierre/ndaal_public_sbom_auditor/-/releases/v0.2.1
[0.1.0]: https://gitlab.com/vPierre/ndaal_public_sbom_auditor/-/tags/v0.1.0