Skip to main content

README

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

# ndaal SBOM Auditor

A single-binary, offline web application to **investigate SBOM (Software
Bill of Materials) files**. Upload an SBOM or import a folder of them, analyse
each with an ensemble of independent tools, review the normalised findings in
one local UI, and export the result in eight formats. No installation, no
network access, no telemetry.

> [English](README.md) · [Deutsch](LIESMICH.md) · [Français](LISEZMOI.md) ·
> [Español](LEEME.md) · [Italiano](LEGGIMI.md)

## What it does

1. **Ingest** — upload an SBOM in the browser, or import every SBOM in a
   configurable folder. CycloneDX (JSON/XML), SPDX (JSON/tag-value and 3.0
   JSON-LD) and syft-json are detected; files are content-hashed and
   deduplicated.
2. **Analyse** — run the enabled tools against the SBOM, each as an isolated
   subprocess under a deadline and a concurrency limit. A missing tool is a
   clean, visibly distinct *skip* — never reported as a clean result.
3. **Findings** — each tool's output is normalised to a common shape, with the
   CVE alias resolved from the tool's GHSA/OSV identifier, and shown per tool
   with severity badges.
4. **Score** — grade the SBOM against all seventeen CISA 2026 minimum
   elements, distinguishing a supplier's explicit `NOASSERTION` from a
   field that is simply absent.
5. **Search** — find which of your SBOMs declare a given component, across
   the whole corpus, by name or package URL.
6. **Export** — write the analysis as SARIF 2.1, SARIF 2.2, Markdown, ODT,
   DOCX, PDF and CSAF 2.1 / 2.2 advisories. **Export** persists the selected
   formats to the server export directory, each accompanied by five
   cryptographic hash sidecars; **Start download** renders the same formats
   in memory and streams them to the browser — one document, or a ZIP bundle
   for several — writing nothing to disk.

## Stored SBOMs dashboard

The home page (`/`) lists every stored SBOM and stays usable as the corpus
grows:

- **Paged** — 25 rows per page, with a "Showing X–Y of N" caption and
  Previous / Page / Next controls.
- **Sortable columns** — click the Name, Format or Components header to sort
  by it; click the header again to reverse the direction.
- **Newest upload first** — the list opens with the most recently uploaded
  SBOM, its time in the Last uploaded column. Uploading an SBOM that is
  already stored moves it back to the top; its detail page keeps the
  original Ingested time next to Last uploaded.
- **Format filter** — narrow the list to one format (CycloneDX JSON/XML,
  SPDX JSON, SPDX 3.0 JSON, SPDX tag-value, Syft JSON or Unknown); a Reset
  button clears the filters while keeping the current sort.
- **Severity column** — a rightmost column, after Last uploaded, shows each
  SBOM's single highest finding severity (Critical, High, Medium, Low or
  Negligible), or "—" when it has no findings yet, with a matching severity
  filter.
- **Full-text search** — a search box, backed by a tantivy full-text index,
  matches an SBOM's name, format and spec version as well as the names and
  package URLs of the components it contains, so a query such as `log4j` or
  `openssl` finds every SBOM that ships that component. The query is
  length-capped and parsed leniently.

## Component search

`/components` answers the inverse question — *which of my SBOMs declare this
component* — across the whole corpus at once, by component name or exact
package URL. It reads a dedicated component index built at ingest, separate
from the dashboard's full-text index.

Because a stale index would make a genuinely affected SBOM read as "not
affected", the page states how many SBOMs are not yet indexed and offers
**Rebuild index**, which re-parses the retained bytes for exactly those. A
search is only complete once that warning is gone.

"Indexed" means the index was **built**, which is not the same as "produced
rows": a metadata-only SBOM declares no components, so it contributes none
and is still fully indexed. The store records the fact of indexing per SBOM
(schema v8, `components_indexed_at`) rather than inferring it from row
existence — inferring it meant a component-free SBOM could never leave the
"not yet indexed" set, so the warning survived every rebuild and the button
reported the same SBOMs as freshly indexed forever. Upgrading migrates
automatically and backfills anything that already had rows; one rebuild
settles the remainder.

## Analyzer ensemble

Each tool is optional at runtime and detected on `PATH` (override the binary
with `SBA_TOOL_<NAME>_BIN`). Findings never depend on any single tool.

| Tool | Role | Verified against |
| --- | --- | --- |
| [syft](https://github.com/anchore/syft) | SBOM normaliser / CycloneDX 1.6 converter | 1.52.0 |
| [grype](https://github.com/anchore/grype) | CVE matcher | 0.119.0 |
| [trivy](https://github.com/aquasecurity/trivy) | Vulnerability scanner | 0.74.0 |
| [osv-scanner](https://github.com/google/osv-scanner) | OSV database lookups | 2.4.0, 2.6.0 |
| [bomber](https://github.com/devops-kung-fu/bomber) | Independent ensemble matcher | 0.5.1 |
| [bomdrift](https://crates.io/crates/bomdrift) | Supply-chain drift / typosquat (SARIF) | 0.9.9 |
| [inspektr](https://crates.io/crates/inspektr) | SBOM vulnerability scanner (JSON) | 0.1.9 |
| [shieldbom](https://crates.io/crates/shieldbom-core) | Offline embedded/IoT CVE scanner (SARIF) | 0.3.0 |
| [provenant](https://crates.io/crates/provenant-cli) | Licence / copyright / provenance (ScanCode) | 1.0.10 |

"Verified against" is the version each invocation was actually exercised with,
not a floor and not a pin: tools are resolved on `PATH` at runtime and any
compatible version works. `osv-scanner` lists two because 2.6.0 was
re-verified after the 2.x line changed its logging — the documented
`scan --lockfile <input> --format json` form is unchanged and still exits
non-zero on findings, so no code change was needed.

The first eight tools contribute CVE-shaped **vulnerability findings**;
`provenant` is different — it reports **licence, copyright, holder and author**
provenance, which the auditor stores and displays as its own finding shape (a
"Licence & provenance" section per run, included in the Markdown export) rather
than discarding it.

The scanners cannot read every format directly: `bomber` consumes only
CycloneDX ≤ 1.6, and none of the scanners read SPDX 3.0 JSON-LD. In both cases
the auditor normalises the input to CycloneDX via `syft convert` first, and
skips with a documented reason when syft is unavailable rather than let a tool
report zero packages. grype's vulnerability database is refreshed once per run
(out of band) so a routine staleness is a fresh scan, not a failed one.

## Vulnerability enrichment

After analysis, each CVE-shaped finding is enriched with three real signals
pulled from a vulnlookup service (default <https://vulnlookup.ndaal.eu>):

- **EPSS** — the exploitation-probability score and its percentile.
- **CISA-KEV** — a flag marking the CVE as known-exploited.
- **nuclei** — whether a public exploit template exists, and its severity.

Each finding shows its KEV, Nuclei, EPSS and Source values, where Source links
back to the vulnlookup record, and findings are ordered most-urgent-first:
known-exploited (KEV) entries lead, then those with the higher EPSS percentile;
findings with no enrichment keep the order the analyzer produced them in. The
enrichment travels into every export format — SARIF 2.1 / 2.2, Markdown, ODT,
DOCX, PDF and CSAF 2.1 / 2.2, where CSAF carries EPSS as a native metric and
records CISA-KEV and nuclei as notes — and the export form now ticks every
format by default.

Configure it under **Settings → Vulnerability enrichment**: an on/off toggle
(enabled by default) and up to three vulnlookup sources, each with a base URL, a
TCP port and a QUIC port (a blank port is treated as 443). Sources 1 and 2
default to the public <https://vulnlookup.ndaal.eu> on port 443; source 3
defaults to a loopback <https://127.0.0.1> on this host's own 8680 (TCP) / 8681
(QUIC) ports, so a co-located vulnlookup is a working fallback out of the box.
The sources are tried in order — the first is primary, the others are fallbacks.

The lookups are made by the auditor's server process, not by the browser, so
the host it runs on must itself reach the source over HTTPS: the client speaks
TLS 1.3 only and trusts the operating system's CA bundle. If it cannot, every
finding stays `Not enriched` while the analysis still completes, and the server
log shows `post-analysis enrichment finished` with a non-zero `failed=` count
(or `post-analysis enrichment failed` when the client could not start at all).
Enrichment runs once every analyzer has finished, so the columns fill in
shortly after the run completes.

When reading the results, check the package each finding was matched to. A KEV
badge on a finding with no package — for example `affects fedora @ 40` — comes
from a match against the operating-system release, not an installed package,
and usually does not apply. CVE-2020-0601 reported against the Go standard
library is the same kind of mismatch: it is a Windows CryptoAPI flaw that Go
fixed in 1.13.6.

To see all three signals on one finding, analyse the `jenkins:2.60.3` image SBOM
from the ndaal_public_sbom_examples repository
(`example/anchore-sbom-examples/results/docker.io_library_jenkins:2.60.3/sbom/`):
Spring4Shell (CVE-2022-22965) and a Jenkins Stapler RCE (CVE-2018-1000861) each
carry a KEV badge, a critical nuclei template and an EPSS above 0.98.

## Security posture

- **Transport:** HTTPS only, TLS 1.3 (rustls + aws-lc-rs) with post-quantum
  key exchange (X25519MLKEM768) offered first. Self-signed dev certificate,
  regenerated at start-up; operator PEM files supported.
- **Memory safety:** written in Rust with `#![forbid(unsafe_code)]`; the lint
  policy denies panicking shortcuts, unchecked arithmetic and unchecked
  indexing in production code.
- **Filesystem confinement:** all data / import / export I/O is confined by a
  cap-std capability handle (path-traversal, symlink and TOCTOU classes
  closed). Stored file names are server-derived, never client-supplied.
- **Storage encryption (optional):** XChaCha20-Poly1305 seals stored SBOM
  bytes and raw tool output while they sit on disk (`--encryption-key-file`).
- **Web hardening:** defence-in-depth response headers on every response, a
  hash-pinned Content-Security-Policy, same-origin checks on every POST, and a
  request-body size limit.
- **No telemetry, no backdoors.** Everything runs locally. Meets the TeleTrusT
  "IT Security made in Germany / Europe" criteria.

### Hardening this cycle

- **Multipart DoS cap** — the upload parser rejects any multipart
  boundary longer than RFC 2046's 70 characters and locates it with a
  linear two-way search, closing an O(body×boundary) CPU-exhaustion path.
- **Fail-closed stored-data decryption** — an undecryptable encrypted blob
  (wrong key or tampering) now raises a hard error instead of being read
  as an empty document that reported "no findings".
- **CVSS validation** — analyzer CVSS base scores are clamped or rejected
  when non-finite or outside [0, 10] before they reach a generated CSAF
  or SARIF, and a cross-tool merge keeps the strongest score.
- **TLS key hardening** — `--tls-key` refuses a PKCS#1 RSA key (no-RSA
  policy) and, on Unix, a group- or world-readable key file.
- **Export-download extension gate** — `GET /export/download` serves only
  names with a recognised export or checksum-sidecar extension, so a
  misconfigured export directory cannot hand out a system file.
- **h2 bump (RUSTSEC-2026-0258)** — the bundled HTTP/2 stack is updated
  to 0.4.18 for unbounded empty DATA frames; `cargo audit` is clean.

## Build and run

Install the published release from crates.io — `cargo install` builds it
from source, `cargo binstall` fetches the prebuilt binary from the GitLab
release:

```bash
cargo install sbom-auditor     # build from source
cargo binstall sbom-auditor    # prebuilt GitLab-release binary
```

Or build this checkout directly (a recent stable Rust toolchain is
required):

```bash
cargo build --release
./target/release/sbom-auditor
```

Then open <https://127.0.0.1:8680> (the browser will warn once about the
self-signed certificate). The top navigation runs Home, Upload, Import,
Export, Components, Tools, Settings, followed by the dark/light theme
switch, language menu and info menu on the right. The interface ships in
49 languages — English, Deutsch and Français plus every European official
language, Norwegian Nynorsk, the three Sámi languages official in Norway
(Northern, Lule and Southern Sámi), and three Asian additions — Chinese
(中文), Hindi (हिन्दी) and Urdu (اردو, right-to-left) — selectable from the
language menu (and remembered per browser). The Supported GUI languages
section below has the full breakdown.

### Common options

```text
--port <PORT>              Listen port, 1025-65535 (default 8680, env SBA_PORT)
--bind <ADDR>              Bind address (default 127.0.0.1, env SBA_BIND)
--data-dir <DIR>           Data directory (SQLite store + stored SBOMs)
--db <PATH>                Database path (default <data-dir>/auditor.db)
--encryption-key-file <F>  Enable storage encryption with this 32-byte key
--tls-cert <F> --tls-key <F>  Operator certificate instead of self-signed
--allow-non-loopback       Permit binding a non-loopback address
--check-update             Check gitlab.com for a newer release, then exit
--self-update              Download + verify + install the latest release
--help                     Full option list
```

Ports: **8680** serves the API and web UI over TLS 1.3 (HTTP/1.1 + HTTP/2);
**8681** is reserved for a future QUIC / HTTP-3 listener (not yet implemented).

## Supported GUI languages

The web UI ships in 49 languages. English, Deutsch and Français are the
maintained, human-authored set and are pinned to the front of the language
switcher; every other language follows in alphabetical order by its English
name.

Three Asian languages joined this cycle: Chinese / 中文 (Mandarin,
Simplified), Hindi / हिन्दी and Urdu / اردو. Urdu is right-to-left — the
application renders `<html dir="rtl">` while it is selected.

Every language beyond the pinned three is machine-translated and pending
native review; any string missing from a translation falls back
automatically to English. The in-page switcher lists each language by its
own native-name endonym and is the authoritative list — consult it rather
than an enumeration here.

This applies to the web UI only. The generated reports (SARIF, CSAF and the
PDF export) and the HTTP API remain English-only.

## Testing

```bash
cargo nextest run          # unit + integration suite
cargo test --doc           # doctests
```

The suites, counted on the 1.6.32 tree:

| Suite | Count | Location |
| --- | --- | --- |
| Unit tests (Rust) | 539 | `src/` |
| of which property tests (proptest) | 72 in 25 modules | `src/` |
| Integration tests (Rust) | 112 in 11 files | `tests/*.rs` |
| Structured fuzz harnesses (bolero) | 4 | `tests/bolero_structured.rs` |
| Fuzz targets (cargo-fuzz, libFuzzer) | 8, seeded with 5,348 corpus files | `fuzz/` |
| Fuzz targets for the CSAF Python tools (atheris) | 3 | `csaf/fuzz/` |
| Model-checking proofs (Kani) | 2 | `kani-harness/` |
| Concurrency models (Loom) | 10 | `loom-harness/` |
| Compile-time invariants (static_assertions) | 82 | `tests/static_assertions_invariants.rs` |
| Snapshot assertions (insta) | 8 | `src/`, `tests/` |
| BDD scenarios (cucumber) | 4 (16 steps) | `tests/features/` |
| SQL regression records (sqllogictest) | 88 | `test/sqllogictest/` |
| API requests (Bruno) | 69 | `test/bruno/` |
| Browser end-to-end tests (Playwright) | 49 in 13 specs | `test/playwright/` |
| AI visual checks (Magnitude, not blocking) | 4 | `test/magnitude/` |
| Load scenarios (Goose) | 2 | `loadtest/` |
| Shell-script tests (bats) | 5,769 in 170 files | beside each script |

Additional harnesses: `loom-harness/` (concurrency permutation models),
`test/bruno/` (API collection), `test/playwright/` (browser E2E),
`test/example-corpus/` (API + GUI upload→analyze→export over the bundled
`example/` SBOM corpus) and `loadtest/` (Goose load scenarios).
`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 via the streamed `POST /export/download`
ZIP; TLS is verified for non-loopback hosts and skipped for localhost's
self-signed certificate, and `--base-url` targets any instance (for
example `https://sbom.ndaal.eu/`). The
`test/magnitude/` suite (Magnitude, `*.mag.ts`) adds AI-driven visual checks
of the navbar order and the export defaults plus Start download; it
complements the deterministic tests, needs an LLM to run and is not part of
the blocking CI gate.

[`scripts/upload_sboms.sh`](scripts/upload_sboms.sh) drives a whole directory
tree against any instance: it uploads each file **and queues the analyzer
ensemble** for it, writes one TSV row per file, and resumes from that TSV so
an interrupted run continues where it stopped. Storing an SBOM does not
analyze it — the analyzers only run once `/sbom/{id}/analyze` is POSTed — so
the analyze step is on by default (`ANALYZE=0` to store only).

```bash
BASE_URL=https://sbom.ndaal.eu ROOT=example scripts/upload_sboms.sh
```

The `example/` directory carries 1,157 `*.json` files (Git LFS). Ingesting
the whole tree yields **1,015 distinct SBOMs**: uploads are content-addressed,
so identical files in different directories collapse onto one record, and the
remainder are legitimately rejected — 79 are grype/trivy *scan reports* rather
than SBOMs (`422`), 14 are zero-byte (`400`), and 24 exceed a 50 MiB
per-request cap. See
[example/PROVENANCE.md](example/PROVENANCE.md). See
[documentation/structure.md](documentation/structure.md) for the module map
and the request flow.

## Documentation

- [REFERENCE.md](REFERENCE.md) — CLI, environment, routes, settings and
  export-format reference
- [documentation/structure.md](documentation/structure.md) — architecture map
- [documentation/User_Guide.md](documentation/User_Guide.md) — end-user
  walkthrough
- [documentation/Administrator_Guide.md](documentation/Administrator_Guide.md)
  — deployment and operations
- [documentation/Developer_Guide.md](documentation/Developer_Guide.md)
  — internals, quality gates and Git hooks, scripting the HTTP surface
- [documentation/Troubleshooting_Guide.md](documentation/Troubleshooting_Guide.md)
  — symptoms, checks, causes and fixes
- [documentation/arc42/en/](documentation/arc42/en/01-introduction-and-goals.md)
  — arc42 architecture views
- [documentation/compliance/CSI_2026_cisa_sbom_minimum_elements_508c.md](documentation/compliance/CSI_2026_cisa_sbom_minimum_elements_508c.md)
  — conformance assessment against the CISA 2026 SBOM Minimum Elements
- [CHANGELOG.md](CHANGELOG.md) — release history

## Licence

Code is licensed Apache-2.0. Bundled UI assets carry their own licences
(Bootstrap MIT, htmx 0BSD, Roboto OFL-1.1); see the per-file `.license`
sidecars under `src/static/`.