<!--
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>
-->
# Developer Guide — ndaal SBOM Auditor
Developer-facing notes on the SBOM Auditor's internals. For the project
structure see [structure.md](structure.md); for the full architecture see
[arc42/en/](arc42/en/01-introduction-and-goals.md); for the exhaustive option
and route tables see [REFERENCE.md](../REFERENCE.md). The sections below
document individual subsystems in depth.
## The Stored-SBOMs dashboard and search
The home page (`GET /`) is a filtered, sorted, paginated table of every stored
SBOM, each row carrying its single highest finding severity, with a free-text
search box over the inventory. Three modules cooperate: `src/routes/home.rs`
(the handler), `src/store.rs` (the SQL page query) and `src/search.rs` (the
full-text index). The data flow for one request is:
- `home()` reads and validates the query string.
- A non-empty `q` goes to the search index, which returns a bounded set of
matching SBOM ids.
- `Store::list_sboms_page` runs one filtered/sorted/paginated SQL query
(restricted to that id set when searching) and returns a `SbomPage`.
### Request parameters
`GET /` accepts six query parameters, each validated against a fixed vocabulary
— an unknown or malformed value falls back to its default rather than erroring:
| Parameter | Meaning | Default |
| ---------- | ---------------------------------------------------- | --------- |
| `sort` | Column: `name` / `format` / `components` / `created` | `created` |
| `dir` | Direction: `asc` / `desc` | `desc` |
| `format` | Restrict to one detected format | none |
| `severity` | Restrict to one highest-severity band | none |
| `page` | 1-based page number | `1` |
| `q` | Free-text search over the inventory | none |
Every link the page renders (sort headers, filter controls, pager) is rebuilt
from this state so it round-trips; only `q` is percent-encoded. The page size is
a fixed `PER_PAGE = 25`, not client-controllable, so paging cost is bounded
regardless of the request. A store read failure degrades to an empty dashboard,
never a `500`.
### The whitelisted sort — the SQL-injection boundary
The sort column is the one place a URL token influences `ORDER BY`, so it is
resolved through a fixed mapping rather than interpolated.
`SbomSort::from_token` turns the raw token into one of four enum variants (or
`None`, which the handler replaces with the default); `SbomSort::sql_column`
then maps that variant to a fixed physical column name:
```rust
// src/store.rs — SbomSort::sql_column
Self::Name => "original_name",
Self::Format => "format",
Self::Components => "component_count",
Self::Created => "created_at",
```
Only that `&'static str` — never the user's token — reaches the query string,
and `SortDir` maps to a literal `ASC` / `DESC` the same way. Every other value
(the format filter, the severity, the id hit set, `LIMIT` / `OFFSET`) is a bound
parameter, not interpolated text.
### Highest severity per SBOM
Each dashboard row shows the single most severe finding across all of the SBOM's
analyzer runs. `list_sboms_page` computes it with a `MIN(rank)` correlated
subquery over the SBOM's runs' findings, where the rank ladder is
`critical = 0 … unknown = 5` (`NULL` when the SBOM has no findings yet):
```text
(SELECT MIN(CASE f.severity
WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2
WHEN 'low' THEN 3 WHEN 'negligible' THEN 4 ELSE 5 END)
FROM analysis_runs r JOIN findings f ON f.run_id = r.id
WHERE r.sbom_id = sboms.id)
```
This ladder is the one `Severity` exposes as `rank()` / `from_rank()` (with
`badge()` for the UI class) in `src/findings.rs`, and the same ordering
`read_findings` uses, so the dashboard and the detail view agree on "highest".
The `severity` filter matches on the computed rank; sorting and paging run over
the derived `(row, sev_rank)` set, and `SbomPage` also carries the unpaged total
for the pager.
### The search module boundary
`src/search.rs` is a hardened wrapper around a tantivy index. `tantivy::` types
never escape it: callers see only `SearchDoc` (one SBOM as it enters the index),
`SearchIndex` (the handle) and a `Vec<i64>` of matching ids — a `TantivyError`
is mapped once into the module-local `SearchError` and never propagated. The
schema indexes `sbom_id` (the unique key), `name`, `format`, `spec_version` and
a joined component-names-plus-purls field, so "find every SBOM containing log4j"
resolves to the ids that carry that component.
### The total query path
`SearchIndex::search` is the untrusted-input boundary and is deliberately
*total* — it never errors and never panics; any failure yields an empty result:
- The query is trimmed and rejected if empty or longer than 1 KiB
(`MAX_QUERY_BYTES`), before any parsing.
- It is parsed with `parse_query_lenient`, so a malformed query degrades rather
than failing.
- Regex queries are never enabled (`allow_regexes` is never called), because an
attacker-chosen pattern over the term dictionary is a CPU / memory amplifier.
- The result window is clamped to `1..=MAX_HITS` (1000), bounding collector
memory regardless of the requested limit.
The handler feeds the returned ids straight into `SbomListQuery.ids`; an empty
hit set correctly renders the no-matches state, and the same filter, sort and
pagination then apply over the subset.
### The RAM index and the startup rebuild
The index is RAM-backed (`Index::create_in_ram`) — there is no on-disk index
directory, which removes that attack surface entirely. SQLite remains the source
of truth; the index is a derived copy:
- Built at start-up. `AppState::new` calls `rebuild_search`, which lists every
stored SBOM, builds a `SearchDoc` per row (reading its component text) and
replaces the index contents in one committed batch. It is best-effort: a
failure is logged and leaves search empty until re-ingest — it never blocks
start-up, because search is an accelerator, not a correctness dependency.
- Kept in sync. `AppState::reindex_search` re-indexes one SBOM after ingest or a
component recompute (via `SearchIndex::index_one`), and a delete calls
`SearchIndex::remove`. Both are best-effort and idempotent, so a stale search
entry can never fail an ingest or a delete.
Because the index lives only in RAM, restart cost scales with the store size and
there is nothing extra to back up — an operational note repeated in the
[Administrator Guide](Administrator_Guide.md#search-index).
## Vulnerability enrichment
After an analysis finishes, `enrich_sbom_findings` (`src/enrichment.rs`)
annotates each finding by CVE with three exploitation signals — EPSS, CISA-KEV
and a nuclei public-template flag — fetched from a configured vulnlookup
service. Enrichment is on by default and is the auditor's only unattended
outbound path. Three concerns stay separate: the wire DTO that parses the
response, the mapping into the internal model, and the cache that fronts the
network.
### Parsing the response at the boundary
The `/enrichment` response is parsed once, into DTOs that never leave the
module. The live shape is `{"enrichments": {"epss", "cisa-kev", "nuclei"}}`,
mirrored by `EnrichmentDto` → `Enrichments` → `EpssDto` / `KevDto` /
`NucleiDto`. The wire name `cisa-kev` is bound with
`#[serde(rename = "cisa-kev")]`, and every field is optional, so a partial or
extended payload degrades to fewer signals instead of failing. The nuclei block
is a boolean flag plus an optional severity string — there is no template id —
and EPSS carries a probability and percentile only, with no wire timestamp.
`dto_to_enrichment(cve_id, dto, source_base, fetched_at) -> CveEnrichment` is
the sole mapping from a parsed DTO to the internal model. It un-nests
`enrichments`, clamps each EPSS float into `[0, 1]` with `clamp_unit` (a `NaN`
or infinity becomes `None`), and sets `source_url` to the CVE's record link,
`{source}api/v1/vulnerability/{cve}`. The endpoint URL itself is built by
`enrichment_url`, which appends `api/v1/vulnerability/{cve}/enrichment` to a
`/`-terminated source, so each configured source is combined with its own TCP
port. The QUIC port is stored but reserved for a future HTTP/3 transport; the
current TCP path ignores it.
### Source order, first answer wins, and the cache
The client walks the configured sources in order: the first `2xx` that parses
wins, and a `404` is a definitive "no data" answer that ends the walk for that
CVE. Lookups are cached in the `cve_enrichment` table (schema v7) and are
cache-first — a CVE whose cached row is younger than `ENRICHMENT_TTL_SECS`
(86,400 s, i.e. 24 h) is served from the cache and never re-queried. Lookup
concurrency is clamped into `1..=MAX_LOOKUP_CONCURRENCY`, so a zero or oversized
setting can neither deadlock nor flood a source.
Findings are ordered — for both the UI and the exports — by
`enrichment_ordering` (`src/export.rs`): KEV-known first, then descending EPSS
percentile (a missing percentile sorts last), with ties left in analyzer order
by a stable sort. A report with no enrichment therefore renders exactly as the
analyzer produced it.
### Test coverage of the parse boundary
The parse boundary is the untrusted-input surface, so it is fuzzed and
property-tested:
- **Fuzz.** `fuzz/fuzz_targets/fuzz_enrichment_parse.rs` drives arbitrary bytes
through the DTO parse and `dto_to_enrichment`, seeded from the real
`fuzz/corpus/fuzz_enrichment_parse/` fixtures (Log4Shell and an EPSS-only
record); it must never panic.
- **Property.** The `proptest!` block in `src/enrichment.rs` covers the total
helpers — `clamp_unit` (any float, `NaN` and infinities included, yields
`None` or a value in `[0, 1]`), `dto_to_enrichment`, `is_safe_cve_id`,
`normalise_source` and `source_base_with_port`.
- **Fixtures.** Unit tests map two real vulnlookup samples end to end —
`tests/fixtures/enrichment/cve-2021-44228.json` (KEV, a critical nuclei
template, near-certain EPSS) and `cve-2005-2541.json` (no nuclei) — and assert
the echoed CVE never renames the finding row.
## Quality gates and Git hooks
Two Git hooks live under `.githooks/`; activate them once per clone with
`git config core.hooksPath .githooks`.
- **`pre-commit`** (on a Rust-touching commit) auto-fixes, then enforces in
order: `rustfmt`, `clippy --workspace --all-targets -- -D warnings`, the test
suite plus doctests, a `RUSTDOCFLAGS="-D warnings" cargo doc` broken-link
check, `typos`, and a **rust-doctor 100/100** health gate. Bypass one commit
with `SKIP_RUST_HOOKS=1 git commit` or `git commit --no-verify`.
- **`pre-push`** runs the heavier, network tier: `cargo audit`, `cargo deny
check`, `cargo semver-checks` (published crates only), and an MSRV
`cargo +<rust-version> check`.
The rust-doctor gate is configured in `rust-doctor.toml` for the installed
0.7.0 (its schema is `blocking`, `[categories]`, `[rules]`, `[structure]` — the
older `verbose`/`fail_on`/`[ignore]` keys are rejected). Every `#[allow]` in the
tree carries a `reason = "…"`; the opinionated structural rules (file/module
-scope allows, unit size, near-duplicate bodies) and the six transitive
duplicate-major-version conflicts are declined with a documented `[rules]`
policy rather than by reshaping cohesive code — dependency-version policy is
owned by `cargo deny` (`deny.toml`). `clippy.toml` bans the `print!` / `println!`
macro family in the runtime binary; build scripts keep a scoped allow for the
cargo directive protocol.
## Driving the HTTP surface — upload, analyze, export
The Auditor is a server-rendered htmx application, **not a JSON REST API**.
There is no `/api/v1`: every mutation is an ordinary HTML form `POST` that
answers `303 See Other` with a `Location` header, and every read returns HTML.
Automating it therefore means three things:
- Do not follow redirects — the `303` **is** the result. The new SBOM's id is
the last path segment of `Location`.
- Send the file as `multipart/form-data`; send everything else as
`application/x-www-form-urlencoded`.
- Poll `GET /sbom/{id}/runs/partial` for completion. Analysis is asynchronous:
the analyze `POST` returns as soon as the runs are queued.
Every example below was executed against both a local `1.6.25` build and the
public instance, and the terminal output is the real captured output.
### Endpoints used
| Method + path | Body | Success | Meaning |
| --- | --- | --- | --- |
| `POST /upload` | `multipart/form-data`, part `file` | `303` → `/sbom/{id}` | Store an SBOM |
| `POST /sbom/{id}/analyze` | empty | `303` → `/sbom/{id}` | Queue the analyzer ensemble |
| `GET /sbom/{id}/runs/partial` | — | `200` HTML fragment | Run status + findings |
| `POST /export/run` | form: `sbom`, `format`×N, `overwrite` | `303` → `/export?sbom={id}&written=N` | Render + write reports |
| `GET /export/download?file=NAME` | — | `200` + `Content-Disposition` | Stream one artifact |
| `GET /healthz`, `GET /readyz` | — | `200` `ok` / `ready` | Liveness / readiness |
### The same-origin gate is the CSRF boundary
`POST /upload`, `/analyze` and `/export/run` all pass through
`same_origin()` in `src/router.rs`. It decides in this order:
- If `Sec-Fetch-Site` is present it wins outright: only `same-origin` and
`none` are accepted. A matching `Origin` does **not** override it.
- Otherwise, if `Origin` is present it must parse as a URI with an **https**
scheme whose authority equals the `Host` header.
- If neither header is present the request is allowed.
That last rule is why a bare `curl` works while a browser-like client can be
rejected. Measured against a running server:
```console
$ curl -sk -o /dev/null -w 'HTTP %{http_code}\n' -X POST https://127.0.0.1:8690/upload \
-F "file=@demo-sbom.cdx.json"
HTTP 303
$ curl -sk -o /dev/null -w 'HTTP %{http_code}\n' -X POST https://127.0.0.1:8690/upload \
-H 'Origin: https://evil.example' -F "file=@demo-sbom.cdx.json"
HTTP 403
$ curl -sk -o /dev/null -w 'HTTP %{http_code}\n' -X POST https://127.0.0.1:8690/upload \
-H 'Sec-Fetch-Site: cross-site' -F "file=@demo-sbom.cdx.json"
HTTP 403
$ curl -sk -o /dev/null -w 'HTTP %{http_code}\n' -X POST https://127.0.0.1:8690/upload \
-H 'Origin: https://127.0.0.1:8690' -F "file=@demo-sbom.cdx.json"
HTTP 303
```
If your HTTP library sets `Origin` automatically, either make it match the
target authority over https or drop the header.
### The sample SBOM
Every example uses this file, committed as
[`documentation/demo/demo-sbom.cdx.json`](demo/demo-sbom.cdx.json). It lives
outside `example/`, because `.gitattributes` routes `example/**/*.json` through
Git LFS and an 806-byte fixture has no business being an LFS object. It is
small enough to paste and deliberately carries two known-vulnerable components
so the analyzers return findings:
```json
{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:6f0c2f1e-6a0e-4f6f-9a4b-2c7b1d3e5a90",
"version": 1,
"metadata": {
"timestamp": "2026-09-20T00:00:00Z",
"component": {
"bom-ref": "demo-app", "type": "application",
"name": "demo-app", "version": "1.0.0"
}
},
"components": [
{
"bom-ref": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1",
"type": "library", "name": "log4j-core",
"group": "org.apache.logging.log4j", "version": "2.14.1",
"purl": "pkg:maven/org.apache.logging.log4j/log4j-core@2.14.1"
},
{
"bom-ref": "pkg:npm/lodash@4.17.15",
"type": "library", "name": "lodash", "version": "4.17.15",
"purl": "pkg:npm/lodash@4.17.15"
}
]
}
```
### CLI — curl
Against a local instance. The development server presents a regenerated
self-signed certificate, so `-k` is required locally and **must not** be used
against a public instance.
```console
$ curl -sk -D - -o /dev/null -X POST https://127.0.0.1:8690/upload \
-F "file=@demo-sbom.cdx.json;type=application/json"
HTTP/2 303
content-type: text/plain; charset=utf-8
location: /sbom/1
x-frame-options: DENY
strict-transport-security: max-age=31536000
content-security-policy: default-src 'self'; img-src 'self' data:; style-src 'self'; ...
x-content-type-options: nosniff
referrer-policy: no-referrer
cross-origin-opener-policy: same-origin
cross-origin-resource-policy: same-origin
cross-origin-embedder-policy: require-corp
content-length: 8
$ curl -sk -D - -o /dev/null -X POST https://127.0.0.1:8690/sbom/1/analyze
HTTP/2 303
location: /sbom/1
content-length: 16
```
Poll until the fragment's `hx-trigger` flips from `every 2s` to `none`. Note
that the attribute is **always present** — the terminal signal is its *value*,
not its absence:
```console
$ curl -sk https://127.0.0.1:8690/sbom/1/runs/partial | grep -o 'hx-trigger="[^"]*"'
hx-trigger="none"
```
Rendered to text, the fragment carries one row per analyzer plus the findings
table:
```console
$ curl -sk https://127.0.0.1:8690/sbom/1/runs/partial \
| sed -E 's/<[^>]+>/|/g' | tr '|' '\n' | grep -vE '^\s*$' | head -20
Tool
Status
Version
Duration
Detail
syft
OK
1.52.0
1s
exit 0
grype
OK
13
0.119.0
1s
exit 0
Findings from
grype
Severity
Vulnerability
Package
```
Export every flavour in one request — repeat the `format` field once per
format — then stream one artifact back:
```console
$ curl -sk -o /dev/null -w '%{http_code} %{redirect_url}\n' \
-X POST https://127.0.0.1:8690/export/run \
--data 'sbom=1' --data 'overwrite=1' \
--data 'format=sarif-2.1' --data 'format=sarif-2.2' \
--data 'format=markdown' --data 'format=odt' \
--data 'format=docx' --data 'format=pdf' \
--data 'format=csaf-2.1' --data 'format=csaf-2.2'
303 https://127.0.0.1:8690/export?sbom=1&written=8
$ curl -sk -D - -o sbom-1.md 'https://127.0.0.1:8690/export/download?file=sbom-1.md'
HTTP/2 200
content-type: text/markdown; charset=utf-8
content-disposition: attachment; filename="sbom-1.md"
content-length: 14965
```
The same three calls against the public instance differ only in the base URL
and in dropping `-k`:
```console
$ curl -sS -o /dev/null -w 'HTTP %{http_code} %{redirect_url}\n' \
-X POST https://sbom.ndaal.eu/upload \
-F "file=@demo-sbom.cdx.json;type=application/json"
HTTP 303 https://sbom.ndaal.eu/sbom/1941
$ curl -sS -w '\n' https://sbom.ndaal.eu/healthz
ok
```
### Python
Requires `requests`. The full flow — upload, analyze, poll, export, download:
```python
import os, re, time, requests
BASE = os.environ.get("BASE", "https://127.0.0.1:8690")
VERIFY = "127.0.0.1" not in BASE and "localhost" not in BASE
FORMATS = ["sarif-2.1", "sarif-2.2", "markdown", "odt",
"docx", "pdf", "csaf-2.1", "csaf-2.2"]
s = requests.Session()
s.verify = VERIFY
# 1. upload -> 303 Location: /sbom/{id}
with open("demo-sbom.cdx.json", "rb") as fh:
r = s.post(f"{BASE}/upload",
files={"file": ("demo-sbom.cdx.json", fh, "application/json")},
allow_redirects=False)
sbom_id = int(r.headers["location"].rsplit("/", 1)[1])
print(f"upload -> HTTP {r.status_code} Location: {r.headers['location']}")
# 2. analyze -> 303; runs are queued, analysis is asynchronous
r = s.post(f"{BASE}/sbom/{sbom_id}/analyze", allow_redirects=False)
print(f"analyze -> HTTP {r.status_code} Location: {r.headers.get('location')}")
# 3. poll until hx-trigger == "none"
while True:
fragment = s.get(f"{BASE}/sbom/{sbom_id}/runs/partial").text
trigger = re.search(r'hx-trigger="([^"]*)"', fragment)
if trigger and trigger.group(1) == "none":
break
time.sleep(2)
cves = sorted(set(re.findall(r"CVE-\d{4}-\d{4,7}", fragment)))
print(f" CVEs: {', '.join(cves)}")
# 4. export every flavour -> 303 ?written=N
payload = [("sbom", str(sbom_id)), ("overwrite", "1")]
payload += [("format", f) for f in FORMATS]
r = s.post(f"{BASE}/export/run", data=payload, allow_redirects=False)
print(f"export -> HTTP {r.status_code} {r.headers.get('location')}")
# 5. download one artifact
r = s.get(f"{BASE}/export/download", params={"file": f"sbom-{sbom_id}.md"})
print(f"download -> HTTP {r.status_code} {r.headers.get('content-type')} "
f"{len(r.content)} bytes")
```
Against a local instance:
```console
$ BASE=https://127.0.0.1:8690 python3 audit_client.py demo-sbom.cdx.json
upload -> HTTP 303 Location: /sbom/1
analyze -> HTTP 303 Location: /sbom/1
run syft OK
run grype OK
run trivy OK
run osv-scanner OK
run bomber OK
run bomdrift OK
run shieldbom OK
run provenant OK
CVEs: CVE-2020-28500, CVE-2020-8203, CVE-2021-23337, CVE-2021-44228, CVE-2021-44832, CVE-2021-45046, CVE-2021-45105, CVE-2025-13465, CVE-2025-68161, CVE-2026-2950, CVE-2026-34477, CVE-2026-34480, CVE-2026-4800
export -> HTTP 303 written=8 (/export?sbom=1&written=8)
download -> HTTP 200 sbom-1.md text/markdown; charset=utf-8 18728 bytes
```
Against the public instance — same script, one environment variable:
```console
$ BASE=https://sbom.ndaal.eu python3 audit_client.py demo-sbom.cdx.json
upload -> HTTP 303 Location: /sbom/2298
analyze -> HTTP 303 Location: /sbom/2298
run syft OK
run grype OK
run trivy OK
run osv-scanner OK
run bomber OK
run bomdrift OK
run shieldbom Failed
run provenant OK
CVEs: CVE-2020-28500, CVE-2020-8203, CVE-2021-23337, CVE-2021-44228, CVE-2021-44832, CVE-2021-45046, CVE-2021-45105, CVE-2025-13465, CVE-2025-68161, CVE-2026-2950, CVE-2026-34477, CVE-2026-34480, CVE-2026-4800
export -> HTTP 303 written=154 (/export?sbom=2298&written=154)
download -> HTTP 200 sbom-2298.md text/markdown; charset=utf-8 15107 bytes
```
Two differences in that output are real and worth understanding rather than
retrying: a single analyzer (`shieldbom`) failed on the public instance while
succeeding locally — a per-tool failure is isolated and never blocks the other
runs — and `written` is 154 rather than 8, which the next section explains.
### Rust
`reqwest` 0.13 gates the builder methods behind features; `form`, `query` and
`multipart` are all required, and the TLS feature is `rustls`, not
`rustls-tls`:
```toml
[dependencies]
reqwest = { version = "0.13", default-features = false, features = [
"blocking", "multipart", "form", "query", "rustls", "rustls-native-certs",
] }
regex = "1"
```
The redirect policy matters: `Policy::none()` keeps the `303` readable.
```rust
use reqwest::blocking::{multipart, Client};
use reqwest::redirect::Policy;
let base = std::env::var("BASE").unwrap_or_else(|_| "https://127.0.0.1:8690".into());
let local = base.contains("127.0.0.1") || base.contains("localhost");
let client = Client::builder()
.redirect(Policy::none()) // read the 303, do not follow it
.danger_accept_invalid_certs(local) // dev self-signed cert only
.build()?;
// 1. upload
let form = multipart::Form::new().part(
"file",
multipart::Part::bytes(std::fs::read(&path)?)
.file_name(path.clone())
.mime_str("application/json")?,
);
let response = client.post(format!("{base}/upload")).multipart(form).send()?;
let location = response.headers()["location"].to_str()?.to_owned();
let id: u64 = location.rsplit('/').next().unwrap_or("0").parse()?;
// 2. analyze
client.post(format!("{base}/sbom/{id}/analyze")).send()?;
// 3. poll until hx-trigger == "none"
let trigger = regex::Regex::new(r#"hx-trigger="([^"]*)""#)?;
let fragment = loop {
let body = client.get(format!("{base}/sbom/{id}/runs/partial")).send()?.text()?;
if trigger.captures(&body).is_some_and(|c| &c[1] == "none") { break body; }
std::thread::sleep(std::time::Duration::from_secs(2));
};
// 4. export every flavour
let mut form: Vec<(&str, String)> = vec![("sbom", id.to_string()), ("overwrite", "1".into())];
form.extend(FORMATS.iter().map(|f| ("format", (*f).to_owned())));
let response = client.post(format!("{base}/export/run")).form(&form).send()?;
// 5. download
let name = format!("sbom-{id}.md");
let response = client.get(format!("{base}/export/download"))
.query(&[("file", &name)]).send()?;
```
Both targets, same binary:
```console
$ BASE=https://127.0.0.1:8690 cargo run --release -- demo-sbom.cdx.json
upload -> HTTP 303 Location: /sbom/1
analyze -> HTTP 303
runs -> all terminal; 13 distinct CVEs
export -> HTTP 303 /export?sbom=1&written=8
download -> HTTP 200 sbom-1.md text/markdown; charset=utf-8 18728 bytes
$ BASE=https://sbom.ndaal.eu cargo run --release -- demo-sbom.cdx.json
upload -> HTTP 303 Location: /sbom/2298
analyze -> HTTP 303
runs -> all terminal; 13 distinct CVEs
export -> HTTP 303 /export?sbom=2298&written=154
download -> HTTP 200 sbom-2298.md text/markdown; charset=utf-8 15107 bytes
```
### The eight report flavours, and why `written` is not the format count
`format` accepts these values (`src/export.rs`); each also has a short alias:
| `format` value | Alias | Written as | Media type |
| --- | --- | --- | --- |
| `sarif-2.1` | `sarif21` | `sbom-{id}-v2.1.0.sarif` | `application/sarif+json` |
| `sarif-2.2` | `sarif22` | `sbom-{id}-v2.2.sarif` | `application/sarif+json` |
| `markdown` | `md` | `sbom-{id}.md` | `text/markdown` |
| `odt` | — | `sbom-{id}.odt` | `application/vnd.oasis.opendocument.text` |
| `docx` | — | `sbom-{id}.docx` | `…wordprocessingml.document` |
| `pdf` | — | `sbom-{id}.pdf` | `application/pdf` |
| `csaf-2.1` | `csaf21` | `sbom-{id}-csaf-v2.1.json` | `application/json` |
| `csaf-2.2` | `csaf22` | `sbom-{id}-csaf-v2.2.json` | `application/json` |
`written=N` in the redirect counts **artifacts, not formats**, and one format
can fan out into many files. The `csaf_split_per_finding` setting (default
`false`) is the reason:
- **off** — each CSAF version renders one rollup advisory carrying every
finding. Eight formats → eight artifacts, which is the `written=8` above.
- **on** — each CSAF version renders the rollup *plus* one advisory per
finding, named `sbom-{id}-csaf-v2.1-0001.json`, `-0002`, … The public
instance runs with it on, so the same eight formats over 73 findings
produced `6 + 2 × (1 + 73) = 154` artifacts.
Every artifact is written with all five checksum sidecars (`.sha-256`,
`.sha-512`, `.sha3-512`, `.blake3-512`, `.shake256-512`), so the eight-artifact
local export leaves 48 files in the export directory (`./data_export` by
default):
```console
$ ls -1 data_export/ | grep -vE '\.(sha-256|sha-512|sha3-512|blake3-512|shake256-512)$'
sbom-1-csaf-v2.1.json
sbom-1-csaf-v2.2.json
sbom-1-v2.1.0.sarif
sbom-1-v2.2.sarif
sbom-1.docx
sbom-1.md
sbom-1.odt
sbom-1.pdf
```
`GET /export/download` allowlists the `file` parameter against the actual
directory listing, so the query string is never used as a path.
### Status codes you will actually hit
All measured against a running instance:
| Status | Endpoint | Cause |
| --- | --- | --- |
| `303` | upload / analyze / export | Success — read `Location` |
| `400` | `POST /upload` | Body is not `multipart/form-data`, or the file part is empty |
| `403` | any gated `POST` | `Origin` mismatch, or `Sec-Fetch-Site: cross-site` |
| `413` | `POST /upload` | Body exceeds `upload_max_bytes` |
| `422` | `POST /upload` | Parsed, but not a recognised SBOM |
`upload_max_bytes` bounds **one request body**, not a cumulative quota, and it
is an operator setting — so the cap is per-instance and you cannot infer it
from the default. It now defaults to 10 GiB, but the public instance is
configured far lower: a 51 MiB file is already `413` there. Size-filter batch
uploads against the *target's* cap, discovered by probing, not against the
default.
The body is buffered in memory before the multipart parse, so this setting is
also the worst-case per-request allocation — see the Administrator Guide before
raising it on a memory-constrained host.
Three of these are terminal for batch tooling and must not be retried:
- `422` — the payload parsed but is not an SBOM. Running the `example/` corpus
against the public instance, every `422` was a `*_grype.json` or
`*_trivy-sbom.json` file: a *scan report* is not an SBOM.
- `400` — in a batch run this is almost always a **zero-byte file** rather than
a malformed request; `example/` contains 14 of them.
- `413` — over the target instance's cap.
Only `5xx` and connection errors are worth a retry with backoff.
[`scripts/upload_sboms.sh`](../scripts/upload_sboms.sh) implements exactly that
policy for a whole directory tree. It pre-filters on size client-side, treats
`303`/`422`/`413`/`400` as terminal, retries everything else with linear
backoff, and writes one TSV row per file which doubles as the resume log — a
re-run skips anything already stored:
```console
$ BASE_URL=https://sbom.ndaal.eu ROOT=example scripts/upload_sboms.sh
Info: ✅ progress: sent=25 failed=0 skipped_oversize=0 skipped_already=0
...
Info: ✅ scanned=1157 sent=1028 failed=105 skipped_oversize=24 skipped_already=0
```
Against a local instance the self-signed certificate needs an explicit opt-in;
verification is never disabled implicitly:
```console
$ BASE_URL=https://127.0.0.1:8680 CURL_INSECURE=1 scripts/upload_sboms.sh
Warning: ⚠️ CURL_INSECURE=1 — TLS certificate verification is DISABLED
```
#### Not every `403` comes from the application
A deployment may sit behind a WAF that answers before the Auditor sees the
request, and it uses the same status code as the CSRF gate. Distinguish them by
the response headers: the application's `403` carries the normal security
header set and a short text body, whereas the public instance's edge returns an
empty body and a `waf-block` header.
```console
$ curl -sS -D - -o /dev/null -X POST https://sbom.ndaal.eu/upload \
-F "file=@vex.json;type=application/json"
HTTP/2 403
waf-block: request
content-length: 0
```
That exact file uploads successfully (`303`) against a local build of the same
version, so a `403` with `waf-block: request` is an edge decision about the
payload — not a malformed request, and not something a different `Origin`
header will fix. Batch tooling should log it separately from a CSRF `403`, and
escalate it to whoever owns the WAF rules rather than retrying.
Uploading the same file twice is content-addressed: the second `POST` returns
the `Location` of the existing SBOM rather than creating a duplicate.