---
title: SBOM — Reference
---

# Software Bill of Materials (SBOM)

This page defines what an SBOM is, why it matters, what the Custodian SBOM
standard requires of registered repos, and how to bring a repo into compliance.

---

## What is an SBOM?

An SBOM (Software Bill of Materials) is an inventory of every component a
piece of software depends on — direct and transitive, runtime and build-time.

For software projects this means: every library installed via pip, npm, cargo,
or any other package manager. For infrastructure repos it means: Ansible itself,
Terraform providers, system tools the playbooks invoke. For container images: OS
packages, base image layers, language runtimes.

The key question an SBOM answers is: **"what exactly is running, and at which
version?"**

---

## What a lockfile is — and why it matters

A **lockfile** is the machine-generated, committed answer to that question for
one package manager. When you run `uv lock`, `npm install`, or `terraform init`,
the tool resolves all transitive dependencies, pins them to exact versions, and
writes those pins to a lockfile (`uv.lock`, `package-lock.json`,
`.terraform.lock.hcl`).

Without a lockfile:

| Problem | Consequence |
|---------|-------------|
| Versions are not pinned | Different machines or CI runs get different versions — one may work, another may not |
| No transitive inventory | You know you depend on `ansible`, but not which version of `paramiko` or `cryptography` it pulls in |
| Vulnerability scanning is imprecise | CVE databases require exact versions; a range like `ansible>=8` can't be scanned |
| Licence auditing is impossible | You can't know the licence of every transitive dependency |
| Reproducibility breaks | Debugging a production incident requires knowing the exact versions in use |

The lockfile is the **unit of SBOM evidence** for package-managed dependencies.
The State Hub ingests lockfiles to populate the SBOM store.

---

## The Custodian SBOM Standard

Every registered repo is assessed against five maturity levels. A repo must
reach **Level 3** to be considered SBOM-compliant.

| Level | Name | Criterion |
|-------|------|-----------|
| **0** | Registered | Repo appears in the State Hub `/repos/` |
| **1** | Manifested | For every ecosystem in use, a **manifest file** exists and is committed (`pyproject.toml`, `package.json`, `Cargo.toml`, `go.mod`, `ansible/requirements.yml`, etc.) |
| **2** | Locked | Every manifest file has a corresponding **lockfile** committed to the repo |
| **3** | Ingested | `last_sbom_at` is not null; the ingested packages cover all detected ecosystems |
| **4** | Current | `last_sbom_at` is within 30 days, or since the last lockfile change |
| **5** | Clean | No unreviewed copyleft flags in direct prod dependencies; no unknown licences in direct deps |

### SBOM gap types

**Type A — Missing manifest**: dependencies exist but nothing declares them.
Example: Ansible is installed on the control node but there is no `pyproject.toml`
declaring `ansible` as a dependency. Fix: create the manifest.

**Type B — Manifest without lockfile**: a `pyproject.toml` or `package.json`
exists but no lockfile has been generated. Fix: run `uv lock` / `npm install`.

**Type C — Lockfile not ingested**: lockfile exists but `custodian ingest-sbom`
has not been run, so the State Hub has no record. Fix: run `custodian ingest-sbom`
from the repo root.

**Type D — Stale ingest**: lockfile exists and was ingested, but has since been
updated (new deps added) without a fresh ingest. Fix: re-run `custodian ingest-sbom`.

**Type E — Ecosystem not supported**: the repo uses an ecosystem the ingest
script doesn't yet parse (Go, Java, Ruby, Ansible Galaxy collections). The
SBOM gap is expected until support is added. Register a contribution (FR) if
the ecosystem is important for your domain.

---

## Per-ecosystem guidance

### Python (uv)
```bash
uv init --no-workspace    # creates pyproject.toml if absent
uv add ansible            # adds dep + resolves transitive tree
uv lock                   # generates or updates uv.lock
git add pyproject.toml uv.lock && git commit
```
Then ingest: `custodian ingest-sbom` (from the repo root)

### Node / npm
`package-lock.json` is generated automatically by `npm install`. Commit it.
Licence metadata is embedded per package — the State Hub reads it directly.

### Rust
`Cargo.lock` is generated automatically by `cargo build` or `cargo check`.
Commit it (for binaries; libraries typically do not commit it, but SBOM
ingestion requires it).

### Terraform
Run `terraform init` in each module directory — this generates
`.terraform.lock.hcl`. The `--scan` mode on `make ingest-sbom` finds it
automatically.

### Ansible (Galaxy collections)
If your playbooks use roles or collections from Ansible Galaxy, add them to
`ansible/requirements.yml`:
```yaml
collections:
  - name: community.general
    version: ">=9.0"
roles: []
```
*Note: `requirements.yml` does not include version pins for Ansible itself.
Use `pyproject.toml` + `uv.lock` to pin the `ansible` pip package.*

### Infra-only repos (Ansible, shell, no Galaxy collections)
The minimum expectation is still a `pyproject.toml` declaring the
control-node pip dependencies (at least `ansible`). This enables:
- Pinning the Ansible version (reproducibility)
- SBOM ingestion (licence + vulnerability auditing)
- A machine-readable baseline for future syft-based assessment

---

## OSS tooling — Syft (recommended for comprehensive assessment)

The State Hub's current ingest relies on hand-rolled lockfile parsers.
A more powerful alternative is **[Syft](https://github.com/anchore/syft)**
(Anchore, Apache 2.0):

- Scans a directory and detects **50+ ecosystems** automatically
- Works even when lockfiles are absent (uses manifest files to derive deps)
- Outputs standard **SPDX** or **CycloneDX** JSON
- Handles: Python, Node, Rust, Go, Java, Ruby, PHP, .NET, Ansible collections,
  Terraform providers, OS packages in container images

```bash
# Install
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin

# Scan a directory
syft dir:/home/worsch/railiance-cluster -o cyclonedx-json > sbom.json
```

Syft integration is tracked as **EP-CUST-002** in the Extension Points
catalogue. When implemented, `make ingest-sbom-syft` will replace the
hand-rolled parsers for comprehensive coverage.

---

## Inter-repo task communication

When a compliance gap is identified in a registered repo, the finding is routed
as an **ecosystem todo**: a state hub task with `[repo:<slug>]` in the title,
created in the target domain's workplan. The target repo's session protocol
surfaces it automatically at next session start.

See the full standard: [`/docs/inter-repo-communication`](/docs/inter-repo-communication)

---

## Ingest commands

### From the repo root (recommended)

```bash
# Scan all lockfiles in the current repo and ingest
custodian ingest-sbom

# Dry run — parse and report without submitting
custodian ingest-sbom --dry-run
```

`custodian ingest-sbom` looks up the repo slug from the State Hub registration
(`local_path` match), then scans the whole tree for all supported lockfile
formats. The repo must be registered first — see `custodian register-project`.

### From the state-hub directory (low-level)

```bash
# Auto-detect lockfile at repo root
make ingest-sbom REPO=<slug> REPO_PATH=/path/to/repo

# Scan entire tree — required for multi-ecosystem repos
make ingest-sbom REPO=<slug> SCAN=1 REPO_PATH=/path/to/repo

# Explicit lockfile
make ingest-sbom REPO=<slug> LOCKFILE=/path/to/uv.lock

# Dry run (parse but do not submit)
.venv/bin/python scripts/ingest_sbom.py --repo <slug> --scan --repo-path /path --dry-run
```

---

## Checking compliance

```bash
# View all repos and their SBOM status
# Dashboard → Repos (http://127.0.0.1:3000/repos)

# API: check last_sbom_at per repo
curl -s http://127.0.0.1:8000/repos/ | python3 -c "
import json, sys
for r in json.load(sys.stdin):
    status = r['last_sbom_at'] or '⚠ NOT INGESTED'
    print(f'{r[\"slug\"]:30} {status}')
"

# API: licence risk summary
curl -s http://127.0.0.1:8000/sbom/report/licences/ | python3 -m json.tool
```
