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