diff --git a/state-hub/dashboard/observablehq.config.js b/state-hub/dashboard/observablehq.config.js index 2a0ce1f..2b0ff54 100644 --- a/state-hub/dashboard/observablehq.config.js +++ b/state-hub/dashboard/observablehq.config.js @@ -22,6 +22,7 @@ export default { { name: "Decisions", path: "/docs/decisions" }, { name: "Decision Health", path: "/docs/decisions-kpi" }, { name: "Progress Log", path: "/docs/progress-log" }, + { name: "SBOM", path: "/docs/sbom" }, ], }, ], diff --git a/state-hub/dashboard/src/docs/sbom.md b/state-hub/dashboard/src/docs/sbom.md new file mode 100644 index 0000000..788614b --- /dev/null +++ b/state-hub/dashboard/src/docs/sbom.md @@ -0,0 +1,210 @@ +--- +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 `make ingest-sbom` has +not been run, so the State Hub has no record. Fix: run `make ingest-sbom`. + +**Type D — Stale ingest**: lockfile exists and was ingested, but has since been +updated (new deps added) without a fresh ingest. Fix: re-run `make 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: `make ingest-sbom REPO= SCAN=1 REPO_PATH=` + +### 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-bootstrap -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 the State Hub or custodian identifies a compliance gap in a registered repo, +the task is communicated through two channels: + +1. **State Hub task** — created in the relevant domain workstream with + `[repo:]` in the title. Visible via `get_state_summary()` at the + start of any domain session. + +2. **Workplan file** — a `workplans/-.md` file is created in the + target repo itself (ADR-001 convention). When you open that repo in Claude + Code, the session protocol surfaces it. + +When working in a registered repo, always run `get_state_summary()` at session +start — the state hub surfaces pending tasks for your domain automatically. + +--- + +## Ingest commands + +```bash +# Auto-detect lockfile at repo root +make ingest-sbom REPO= REPO_PATH=/path/to/repo + +# Scan entire tree — required for multi-ecosystem repos +make ingest-sbom REPO= SCAN=1 REPO_PATH=/path/to/repo + +# Explicit lockfile +make ingest-sbom REPO= LOCKFILE=/path/to/uv.lock + +# Dry run (parse but do not submit) +.venv/bin/python scripts/ingest_sbom.py --repo --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 +``` diff --git a/state-hub/dashboard/src/sbom.md b/state-hub/dashboard/src/sbom.md index fc6b024..9b7716b 100644 --- a/state-hub/dashboard/src/sbom.md +++ b/state-hub/dashboard/src/sbom.md @@ -40,6 +40,12 @@ const isCopyleft = spdx => spdx && COPYLEFT_KW.some(k => spdx.toUpperCase().inc # SBOM +```js +import {withDocHelp} from "./components/doc-overlay.js"; +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/sbom"); } +``` + ## Overview ```js