From c6b67191cbc32ff847e09e8998c8f1782b980c02 Mon Sep 17 00:00:00 2001 From: "Piotr P. Karwasz" Date: Thu, 29 Oct 2026 19:19:51 +0100 Subject: [PATCH 1/2] feat(sbom): add the sbom-pipeline contract Define the contract for integrating SBOM generation into a project's build, independently of the build ecosystem: one CycloneDX 1.7 JSON SBOM per distributable artifact, with the artifact, its components and their licenses described the same way whatever generator produced them. Content bundled into an artifact, above all content from another ecosystem (a JavaScript UI inside a WAR), is described in the artifact's own SBOM, so a consumer never has to resolve an SBOM in an ecosystem it does not use: what a bundled component embeds is nested under it as an assembly, while everything the environment must provide is listed once, at the top level, with isExternal set. SBOMs are reproducible, and published next to their artifact through the ecosystem's own channel. The source release gets an SBOM too, listing every file whose license or copyright attribution differs from the project's own: the machine-readable counterpart of the release's LICENSE and NOTICE files, and produced from them. The contract takes no position on which SBOM generator a project uses: the planned Maven, Gradle, JS, Python, Go and Rust adapters describe what any generator in their ecosystem has to produce, and name existing generators only as examples. The adapters and the consuming skills follow separately. Generated-by: Claude Code (Opus 5.5) --- .github/labeler.yml | 6 + docs/labels-and-capabilities.md | 2 + docs/vendor-neutrality.md | 3 +- tools/sbom-pipeline/README.md | 364 ++++++++++++++++++ .../src/skill_and_tool_validator/__init__.py | 1 + .../src/vendor_neutrality_score/__init__.py | 4 + 6 files changed, 379 insertions(+), 1 deletion(-) create mode 100644 tools/sbom-pipeline/README.md diff --git a/.github/labeler.yml b/.github/labeler.yml index 977f027e5..f9d356903 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -533,6 +533,12 @@ contract:report-relay: - any-glob-to-any-file: - 'tools/forwarder-relay/**' +contract:sbom-pipeline: + - any: + - changed-files: + - any-glob-to-any-file: + - 'tools/sbom-pipeline/**' + contract:scan-format: - any: - changed-files: diff --git a/docs/labels-and-capabilities.md b/docs/labels-and-capabilities.md index ec76708c9..4e8755a45 100644 --- a/docs/labels-and-capabilities.md +++ b/docs/labels-and-capabilities.md @@ -149,6 +149,7 @@ framework substrate: | `contract:security-tracker` | contract | Where the security skills keep embargoed reports: body fields, status rollup, attachments, a confidentiality guard, and a publish step for working-copy backends (GitHub, local files, private Jira). | | `contract:body-field` | contract | How the `### ` fields of a tracker issue body are found, read and rewritten: field boundaries, closed code blocks, the refusals, and a language-neutral conformance suite every implementation passes. | | `contract:hook-manager` | contract | Git-hook managers (prek, pre-commit, lefthook, husky, …) under the secure agent setup: where a manager's install puts its hook, and how its home is split between sandboxed and host runs. | +| `contract:sbom-pipeline` | contract | SBOM generation in a project's build: one CycloneDX 1.7 SBOM per distributable artifact, with the same meaning whatever ecosystem produced it, and content from other ecosystems embedded as assemblies. | | `substrate:analytics` | substrate | Read-only metrics / dashboards / renderers. | | `substrate:sandbox` | substrate | Agent isolation, egress control, settings audit. | | `substrate:action-guard` | substrate | Deterministic pre-tool-use command guards. | @@ -393,6 +394,7 @@ or a contract-free mix of substrates (e.g. `tools/spec-inventory` is | [`tools/vcs`](../tools/vcs/) | `contract:source-control` | Backend-dispatching implementation of the source-control (VCS) capability ([`tools/github/source-control.md`](../tools/github/source-control.md)); complete Git, Mercurial (Hg), Fossil, and Subversion (SVN) backends | | [`tools/sourcehut`](../tools/sourcehut/) | `contract:tracker` + `contract:source-control` + `contract:mail-archive` | SourceHut (sr.ht) forge bridge: todo.sr.ht, lists.sr.ht, builds.sr.ht, and git/hg repository reads | | [`tools/hook-manager`](../tools/hook-manager/) | `contract:hook-manager` | Contract for git-hook managers under the secure agent setup: the install-target and home rules, and the registry of adapters (prek, overcommit, lefthook and captainhook today) that the hook-manager shim, the whole-user dispatcher and `sandbox-lint` are generated from | +| [`tools/sbom-pipeline`](../tools/sbom-pipeline/) | `contract:sbom-pipeline` | Contract for SBOM generation in a project's build: the format, subject, component, assembly, reproducibility and distribution rules, the pipeline operations and conformance checks, and the per-ecosystem adapters (Maven, Gradle, JS, Python, Go, Rust; all planned) | | [`tools/tracker`](../tools/tracker/) | `contract:tracker` | Interface spec for the tracker contract's read-only contributor-activity queries (issues filed, triaged and commented on; issues created; first maintainer reply). Pure interface spec; GitHub and Jira implement it, and the rest of the contract is described by `tools/github/tool.md` | | [`tools/body-field-contract`](../tools/body-field-contract/) | `contract:body-field` | The body-field contract: how every reader and writer finds a tracker body's `### ` fields, with a language-neutral conformance suite run against every implementation. Pure interface spec; implementations: `tools/github-body-field/` (reference), `tools/vetted-ops/`, `tools/local-tracker/`, `tools/jira/` | | [`tools/security-tracker`](../tools/security-tracker/) | `contract:security-tracker` | Adapter contract for the security tracker the `security-*` skills read and write, selected by `tracker.platform`. Pure interface spec; backends: `tools/github/` (default), `tools/local-tracker/`, `tools/jira/` | diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index 050137d7b..0d14b0712 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -582,7 +582,7 @@ generated block below. -**Overall vendor-neutrality score: 16/18 capability contracts (89%).** Generated by [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/); re-run it to refresh this section. +**Overall vendor-neutrality score: 17/19 capability contracts (89%).** Generated by [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/); re-run it to refresh this section. | Capability contract | Neutral? | Class | Backends today | Basis | |---|---|---|---|---| @@ -604,6 +604,7 @@ generated block below. | `contract:security-tracker` | ✅ | vendor-backed | Atlassian, agnostic | 2 backend vendors: Atlassian, agnostic; partial foundation, not counted: asf-security-dash | | `contract:body-field` | ✅ | agnostic | Atlassian, GitHub, agnostic | vendor-neutral by construction — one spec serves every backend | | `contract:hook-manager` | ❌ | vendor-backed | — | no backend implemented yet | +| `contract:sbom-pipeline` | ✅ | agnostic | — | vendor-neutral by construction — one spec serves every backend | **Per-skill assessment: 83/83 skills carry no vendor lock-in.** A skill is *capability-pure* when it names no backend at all, *portable* when every backend it names has an alternative (its contract is green), and *vendor-coupled* only when it reaches for a backend that is the sole implementation of a capability. diff --git a/tools/sbom-pipeline/README.md b/tools/sbom-pipeline/README.md new file mode 100644 index 000000000..52aaf1983 --- /dev/null +++ b/tools/sbom-pipeline/README.md @@ -0,0 +1,364 @@ + + + + + +- [tools/sbom-pipeline/](#toolssbom-pipeline) + - [Prerequisites](#prerequisites) + - [Why a contract](#why-a-contract) + - [The rules](#the-rules) + - [1. Format and granularity](#1-format-and-granularity) + - [2. The subject](#2-the-subject) + - [3. Components](#3-components) + - [4. Assemblies](#4-assemblies) + - [5. Reproducibility](#5-reproducibility) + - [6. Distribution](#6-distribution) + - [7. Source releases](#7-source-releases) + - [Operations](#operations) + - [Conformance checks](#conformance-checks) + - [Adapters](#adapters) + - [Adding an adapter](#adding-an-adapter) + - [Configuration](#configuration) + - [Consumers](#consumers) + - [Open questions](#open-questions) + + + + + +# tools/sbom-pipeline/ + +**Capability:** contract:sbom-pipeline + +**Kind:** interface + +**Vendor:** agnostic + +This file defines the contract for an **SBOM pipeline**: +the part of a project's build that produces a Software Bill of Materials +for every artifact the project distributes. +Each build ecosystem (Maven, Gradle, npm / Yarn / pnpm, Python, Go, Rust) has its own SBOM generators, +and each generator fills the CycloneDX format in its own way. +The contract states what every SBOM must say, +regardless of the ecosystem and the generator that produced it. +It takes **no position on which generator a project uses**: +each adapter describes an ecosystem, +what any generator used there has to be configured to say, +and what has to be added after it runs; +the generators an adapter names are existing examples, not requirements. + +The contract is about integrating SBOM generation into a project's build, once. +It is not a release step: +a project that satisfies it produces conformant SBOMs on every build, +and the release skills only find them there. + +## Prerequisites + +- **Runtime:** None of its own — this file is a contract *specification* (pure Markdown). + Concrete prerequisites belong to each ecosystem adapter. +- **CLIs:** None for the contract itself; + the SBOM generator is the project's choice, and each adapter names known examples for its ecosystem. +- **Credentials / auth:** None. +- **Network:** None for the contract; + generators resolve dependencies through the ecosystem's own registry during the build. + +## Why a contract + +An SBOM is useful to the extent that someone outside the project can read it. +Within one ecosystem, +the ecosystem's own metadata (a POM, a `package-lock.json`, a lock file) +already tells its tools most of what an SBOM would. +The value of an SBOM is where ecosystems meet: +a consumer in one ecosystem reading what an artifact from another contains, +and a vulnerability scanner or policy tool that reads every ecosystem the same way. + +That only works when the SBOMs mean the same thing, whatever produced them. +Generators disagree on exactly the points that matter across ecosystems: +how the artifact itself is described, +whether a component carries a package URL, +how licenses are expressed, +and above all what happens to content from another ecosystem bundled into the artifact, +which a generator for the artifact's own ecosystem does not see at all. +The contract fixes those points; +the adapters say how to reconcile a generator's output with it. + +## The rules + +### 1. Format and granularity + +Every SBOM is a **CycloneDX 1.7** document in **JSON**. + +There is **one SBOM per distributable artifact**: +each package the project publishes to an ecosystem registry +(a Maven JAR or WAR, an npm package, a wheel, a Go module, a crate) +or to its download area, +and the source release itself +(see [Source releases](#7-source-releases)). +An SBOM describes exactly one artifact; +a project does not publish one combined SBOM for everything it builds, +and an artifact's SBOM does not depend on any other SBOM to be complete +(see [Assemblies](#4-assemblies)). + +### 2. The subject + +`metadata.component` describes the artifact the SBOM belongs to: + +- `type`, `name` and `version` of the artifact; +- a `purl` in the artifact's own ecosystem + (`pkg:maven/…`, `pkg:npm/…`, `pkg:pypi/…`, `pkg:golang/…`, `pkg:cargo/…`); +- `licenses`, as an SPDX license expression; +- the project's organization as `supplier`, + taken from the project's configuration rather than written into the build by hand, + so every artifact of the project names its supplier the same way. + +`metadata.lifecycles` records the `build` phase: +the SBOM is produced from the build that produced the artifact, +not from a scan of the source tree. + +### 3. Components + +Every component in a package's SBOM carries: + +- a `bom-ref` unique within the document; +- `name`, and a `version` + (or, for an external component, a `versionRange`; see [Assemblies](#4-assemblies)); +- a `purl` whenever its ecosystem has a package-URL type; +- `licenses`, as an SPDX license expression or SPDX license ID, + whenever the ecosystem's metadata declares one; +- `hashes` whenever the generator has the artifact the component was resolved to. + +The `dependencies` graph covers every component in the document, nested ones included, +and only components that ship in the artifact or are required to run it appear in it. +Build-only and test-only dependencies are left out, +or marked `scope: excluded` when an ecosystem's generator cannot leave them out. + +### 4. Assemblies + +Every component is either **embedded**, +physically contained in the artifact, +or **external**, +required at runtime but provided by the environment the artifact is deployed in. +The SBOM records the difference with the component's `isExternal` flag, +and with where the component sits in the document: + +- **Components of the artifact itself** are listed in the top-level `$.components`. + An embedded one leaves `isExternal` at its default, `false`; + an external one has `isExternal: true`. +- **Components of an embedded component** form an **assembly**: + what that component embeds is listed in its own nested `components` array, + with `isExternal` left at `false`. + What it requires externally is **not** nested: + it is listed in the top-level `$.components` with `isExternal: true`, + because the requirement passes up to the artifact that contains the component. + The `dependencies` graph still records the external requirement on the component that has it. + +So `isExternal: true` appears only in the top-level `$.components`, +which lists, for the whole artifact, everything the environment has to provide. +An external component may give the versions it accepts as a `versionRange` instead of a `version`. + +The case the contract exists for is content from another ecosystem. +A JavaScript UI bundled into a WAR is an embedded component of the WAR, +the npm packages bundled into that UI are nested under the UI component, +and the servlet API that the WAR's container provides is external: + +```text +metadata.component pkg:maven/org.example/app-web@1.2.0?type=war +components[] +├─ app-ui pkg:npm/%40example/app-ui@1.2.0 +│ └─ components[] +│ ├─ react pkg:npm/react@19.1.0 +│ └─ … +├─ commons-lang3 pkg:maven/org.apache.commons/commons-lang3@3.18.0 (WEB-INF/lib) +└─ jakarta.servlet-api pkg:maven/jakarta.servlet/jakarta.servlet-api@6.1.0 isExternal: true +``` + +The embedded content is described **in the SBOM itself**, +not by a reference to an SBOM published in the other ecosystem: +a consumer must be able to read an artifact's full contents from that artifact's SBOM alone, +without knowing how to find or fetch SBOMs in an ecosystem it does not use. +The ecosystem that produced the embedded content generates its part with its own adapter, +and the pipeline grafts that part into the container's SBOM +(see [`embed`](#operations)). + +The same rule applies within one ecosystem +whenever content is copied into the artifact rather than resolved by the consumer: +classes shaded into a JAR, vendored Go modules, a native library inside a wheel. + +### 5. Reproducibility + +Two builds of the same source produce the same SBOM, byte for byte, +so the SBOM can be part of a reproducible-build comparison: + +- `serialNumber` is a name-based UUID (version 5) derived from the subject's `purl`, + not a random one, + and `version` is `1`; +- `metadata.timestamp` is taken from `SOURCE_DATE_EPOCH`; +- arrays are sorted by `bom-ref`, + and nothing host-specific (absolute paths, user names, tool cache locations) is recorded. + +The serial number departs from a recommendation of the specification, +which says every SBOM *should* have a unique serial number +even when its contents have not changed. +The contract trades that for reproducibility, +and follows the specification's own model of identity instead: +the serial number identifies *the SBOM of one artifact version*, +and a corrected SBOM for the same artifact keeps the serial number and increments `version`. + +### 6. Distribution + +An SBOM is published **next to its artifact, through the artifact's own channel**, +so a consumer finds it where they found the artifact. +Each adapter declares the channel for its ecosystem +(for example, the `cyclonedx` classifier on a Maven artifact, +or the SBOM directory of a wheel's `.dist-info`). +An artifact published to the project's download area carries its SBOM beside it, +with the same checksums and signature as the artifact. + +### 7. Source releases + +The source release is a distributable artifact too, and has an SBOM of its own. +Where a package's SBOM records the packages it contains, +the source SBOM records the **files** that do not share the project's terms: +every file in the source release whose license or copyright attribution +differs from the project's own is a component, with + +- `type: file`, and the file's path within the source release as its `name`; +- `hashes` of the file; +- `licenses`, as an SPDX license expression, + and `copyright`, as stated for that file. + +Files that came into the source tree together (a copied third-party library) +may be nested as an assembly under a component describing where they came from, +with that component's `purl` and `version` when it has them; +each file is still listed. +Files under the project's own license and copyright are not listed: +the subject's `licenses` and `supplier` already cover them. + +`metadata.lifecycles` records the `pre-build` phase: +the source SBOM describes the source, not a build of it. + +No ecosystem's metadata records the license and copyright of individual files, +but a project that follows its release policy already has that data: +the release's `LICENSE` file names the bundled files under other licenses, +and its `NOTICE` file carries the copyright attributions they require. +The source SBOM is their machine-readable counterpart, +produced from them and required to agree with them. +A project may also check them against the files' headers, +or with a license scanner of its choice; +as for package SBOMs, the contract fixes what the source SBOM says, +not which tool produces it. + +## Operations + +A pipeline built on the contract runs the steps below; +each adapter implements the ones that are ecosystem-specific. + +| Operation | Ecosystem-specific | What it does | +|----------------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `detect(checkout)` | yes | Finds the ecosystem's build files and the distributable artifacts they produce. | +| `configure(artifact)` | yes | The one-time change to the build: adds and configures a generator of the project's choice, or configures the one it already uses. | +| `generate(artifact)` | yes | Runs during the build and writes the artifact's SBOM. | +| `generate-source(release)` | no | Writes the source release's SBOM from its `LICENSE` and `NOTICE` files ([Source releases](#7-source-releases)). | +| `embed(container, part)` | no | Grafts the SBOM of embedded content into the container's SBOM ([Assemblies](#4-assemblies)): the part's embedded components are nested under the component that contains them, its external components are moved to the top-level `$.components` (merged with the ones already there), `bom-ref`s are kept unique, and the `dependencies` graphs are merged. | +| `normalize(sbom)` | no | Applies what generators do not: supplier, serial number, timestamp, ordering ([Reproducibility](#5-reproducibility)). | +| `validate(sbom)` | no | Runs the [conformance checks](#conformance-checks). | +| `publish(artifact, sbom)` | yes | Attaches the SBOM to the artifact through the ecosystem's channel ([Distribution](#6-distribution)). | + +`embed`, `normalize` and `validate` are the same for every ecosystem, +and are implemented once, by a deterministic tool, +not by each adapter. +`generate-source` is the same for every ecosystem too: +it reads the release's `LICENSE` and `NOTICE`, +which every release carries whatever its ecosystem. + +## Conformance checks + +`validate` reports each check as passed, failed, or for review, +with the offending `bom-ref`: + +| ID | Check | +|-----|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| C1 | The document validates against the CycloneDX 1.7 JSON schema. | +| C2 | `metadata.component` has a `type`, `name`, `version`, `purl`, `licenses` and `supplier`, and the supplier matches the project's configuration. | +| C3 | Every component has a `bom-ref`, a `name`, and a `version` (or, if external, a `versionRange`; a file in a source SBOM has neither), and every `bom-ref` is unique. | +| C4 | Every component of an ecosystem with a package-URL type has a `purl`. | +| C5 | Every `licenses` entry is a valid SPDX expression or SPDX license ID; a named license (`license.name`, not on the SPDX list) is reported for review. | +| C6 | Every `bom-ref` named in `dependencies` exists in the document, and in a package SBOM every component appears in `dependencies`. | +| C7 | `serialNumber` is the version 5 UUID derived from the subject's `purl`, and `metadata.timestamp` matches `SOURCE_DATE_EPOCH`. | +| C8 | No nested component has `isExternal: true`, and every component that a nested component depends on in `dependencies` is either nested in the same assembly or external in the top-level `$.components`. | +| C9 | In a package SBOM, every embedded component in the top-level `$.components` has the same `purl` type as the subject. | +| C10 | In a source SBOM, every file component has `hashes`, `licenses` and `copyright`, and every license other than the project's own appears in the release's `LICENSE` file. | + +C9 and the `LICENSE` half of C10 are heuristics, reported for review, not as failures. +An embedded component from another ecosystem at the top level (C9) +is usually one whose own SBOM was flattened instead of grafted as an assembly, +but some are legitimate (a native library copied into a wheel). +A `LICENSE` file (C10) names licenses in prose, +which does not always match an SPDX identifier. + +## Adapters + +One adapter per ecosystem, under `tools/sbom-pipeline-/`. +The generators below are examples of existing tools for each ecosystem, not requirements: +a project uses whichever it prefers, +and one that already produces SBOMs keeps its generator +and adds only what `normalize` has to fill in. + +| Adapter | Status | Example generators | Notes | +|----------|---------|----------------------------------------------------------------|----------------------------------------------------------------------------| +| `maven` | planned | `org.cyclonedx:cyclonedx-maven-plugin` | Many ASF projects already inherit this one from the ASF parent POM. | +| `gradle` | planned | `org.cyclonedx.bom` Gradle plugin | | +| `js` | planned | `@cyclonedx/cyclonedx-npm`, `@cyclonedx/yarn-plugin-cyclonedx` | One adapter for npm, Yarn and pnpm; they share the `npm` package-URL type. | +| `python` | planned | `cyclonedx-py` | The SBOM goes into the wheel's `.dist-info/sboms/` (PEP 770). | +| `go` | planned | `cyclonedx-gomod` | | +| `rust` | planned | `cargo-cyclonedx` | | + +Whether each generator emits CycloneDX 1.7 natively is recorded per adapter; +one that does not yet is upgraded by `normalize`, +and the adapter says so. + +## Adding an adapter + +An adapter README declares, for its ecosystem: + +- how `detect` recognizes the ecosystem and its distributable artifacts; +- the generators it knows of, each with the version the adapter was checked against, + and the build-file change `configure` makes for each; +- for each rule above and each generator, which fields the generator fills + and which `normalize` has to fill instead; +- how a part from this ecosystem is produced + when it is embedded in another ecosystem's artifact; +- the `publish` channel. + +## Configuration + +The contract reads one value from the adopting project's configuration +(`/project.md`, else its organization, else the framework default): +the **supplier** written into every SBOM's `metadata.component` and checked by C2. +Which key holds it is not settled yet +(see [Open questions](#open-questions)). +Each adapter documents any ecosystem-specific keys it adds. + +## Consumers + +| Skill | Status | Use | +|---------------------------|---------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `sbom-pipeline-integrate` | planned | Detects every ecosystem in a checkout, applies each adapter's `configure`, wires `generate-source`, `embed`, `normalize` and `validate` into the build, and hands back a commit for review. | +| `sbom-pipeline-verify` | planned | Read-only: runs `validate` against the SBOMs a build produced. | + +## Open questions + +- **Source release identity.** + A source release belongs to no package ecosystem, + so which `purl` type identifies it in the source SBOM's `metadata.component` + (and so derives its serial number) is not settled. +- **Supplier configuration key.** + Which key of the project or organization manifest names the supplier; + an organization-level default (the ASF for every ASF project) + would let most projects declare nothing. +- **Generator versions.** + Whether the generator versions an adapter was checked against are recorded in a `registry.json` here, + as [`hook-manager`](../hook-manager/) does for its adapters, + or only in each adapter's README. diff --git a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py index c0d204e40..a187548b7 100644 --- a/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py +++ b/tools/skill-and-tool-validator/src/skill_and_tool_validator/__init__.py @@ -401,6 +401,7 @@ "contract:security-tracker", "contract:body-field", "contract:hook-manager", + "contract:sbom-pipeline", "substrate:analytics", "substrate:sandbox", "substrate:action-guard", diff --git a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py index e2de672e3..149feb805 100644 --- a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py +++ b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py @@ -121,6 +121,10 @@ VENDOR_BACKED, "Git-hook managers under the secure setup: install target and sandbox / host homes (prek, …)", ), + "contract:sbom-pipeline": ( + AGNOSTIC, + "SBOM generation in the build: one CycloneDX 1.7 SBOM per distributable artifact, any ecosystem", + ), } # Which capability *contract* a skill actually invokes, keyed by From 963a3ca9c5089b092d6d14c00d1b35a1f5ada394 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sat, 14 Nov 2026 13:43:38 +0900 Subject: [PATCH 2/2] fix(sbom): settle the review threads on the sbom-pipeline contract - Rule 2: the subject purl carries the qualifiers that tell a release's artifacts apart, so no two share a serial number. - Rule 3: bom-refs are derived deterministically; normalize removes scope: excluded components, so C6 holds. - Rule 4: a requirement the artifact embeds is met inside it; distinct external requirements keep their own top-level entries. - Rule 5: a build-produced SBOM has version 1; every array has a canonical sort key, applied by normalize and checked by C7. - Rule 6: next to or inside the artifact, as its channel dictates. - Rule 7: the inventory comes from the source tree, cross-checked against LICENSE and NOTICE; provisional until the source purl type is settled. - C8 allows an edge to a top-level embedded component; new C11. - vendor-neutrality: VENDOR_BACKED until adapters ship (regenerated). - New spec tools/spec-loop/specs/sbom-pipeline.md, indexed. Generated-by: Claude Opus 5 --- docs/vendor-neutrality.md | 4 +- tools/sbom-pipeline/README.md | 65 +++++++--- tools/spec-loop/specs/README.md | 3 +- tools/spec-loop/specs/overview.md | 1 + tools/spec-loop/specs/sbom-pipeline.md | 118 ++++++++++++++++++ .../src/vendor_neutrality_score/__init__.py | 2 +- 6 files changed, 170 insertions(+), 23 deletions(-) create mode 100644 tools/spec-loop/specs/sbom-pipeline.md diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index 9b24de63a..71503a497 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -582,7 +582,7 @@ generated block below. -**Overall vendor-neutrality score: 17/19 capability contracts (89%).** Generated by [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/); re-run it to refresh this section. +**Overall vendor-neutrality score: 16/19 capability contracts (84%).** Generated by [`tools/vendor-neutrality-score`](../tools/vendor-neutrality-score/); re-run it to refresh this section. | Capability contract | Neutral? | Class | Backends today | Basis | |---|---|---|---|---| @@ -604,7 +604,7 @@ generated block below. | `contract:security-tracker` | ✅ | vendor-backed | Atlassian, agnostic | 2 backend vendors: Atlassian, agnostic; partial foundation, not counted: asf-security-dash | | `contract:body-field` | ✅ | agnostic | Atlassian, GitHub, agnostic | vendor-neutral by construction — one spec serves every backend | | `contract:hook-manager` | ❌ | vendor-backed | — | no backend implemented yet | -| `contract:sbom-pipeline` | ✅ | agnostic | — | vendor-neutral by construction — one spec serves every backend | +| `contract:sbom-pipeline` | ❌ | vendor-backed | — | no backend implemented yet | **Per-skill assessment: 83/83 skills carry no vendor lock-in.** A skill is *capability-pure* when it names no backend at all, *portable* when every backend it names has an alternative (its contract is green), and *vendor-coupled* only when it reaches for a backend that is the sole implementation of a capability. diff --git a/tools/sbom-pipeline/README.md b/tools/sbom-pipeline/README.md index 52aaf1983..bb7b9d5bc 100644 --- a/tools/sbom-pipeline/README.md +++ b/tools/sbom-pipeline/README.md @@ -107,7 +107,10 @@ and an artifact's SBOM does not depend on any other SBOM to be complete - `type`, `name` and `version` of the artifact; - a `purl` in the artifact's own ecosystem - (`pkg:maven/…`, `pkg:npm/…`, `pkg:pypi/…`, `pkg:golang/…`, `pkg:cargo/…`); + (`pkg:maven/…`, `pkg:npm/…`, `pkg:pypi/…`, `pkg:golang/…`, `pkg:cargo/…`), + with the qualifiers that tell this artifact apart from the release's other artifacts + (`classifier` and `type` for Maven, `file_name` for a Python wheel), + so no two artifacts share a subject `purl`; - `licenses`, as an SPDX license expression; - the project's organization as `supplier`, taken from the project's configuration rather than written into the build by hand, @@ -121,7 +124,10 @@ not from a scan of the source tree. Every component in a package's SBOM carries: -- a `bom-ref` unique within the document; +- a `bom-ref` unique within the document and derived from the component alone: + its `purl`, else `name@version`, + prefixed with the containing component's `bom-ref` when it is nested, + so two runs of any generator give the same values; - `name`, and a `version` (or, for an external component, a `versionRange`; see [Assemblies](#4-assemblies)); - a `purl` whenever its ecosystem has a package-URL type; @@ -131,8 +137,10 @@ Every component in a package's SBOM carries: The `dependencies` graph covers every component in the document, nested ones included, and only components that ship in the artifact or are required to run it appear in it. -Build-only and test-only dependencies are left out, -or marked `scope: excluded` when an ecosystem's generator cannot leave them out. +Build-only and test-only dependencies are left out. +A generator that cannot leave them out marks them `scope: excluded`, +and `normalize` removes them, +so a published SBOM never carries a `scope: excluded` component. ### 4. Assemblies @@ -149,14 +157,22 @@ and with where the component sits in the document: - **Components of an embedded component** form an **assembly**: what that component embeds is listed in its own nested `components` array, with `isExternal` left at `false`. - What it requires externally is **not** nested: - it is listed in the top-level `$.components` with `isExternal: true`, + What it requires externally is **not** nested. + When the artifact itself embeds that requirement + (a shaded library that needs `commons-lang3`, which the WAR ships in `WEB-INF/lib`), + the requirement is met inside the artifact, + and the `dependencies` graph points at that top-level embedded component. + Otherwise it is listed in the top-level `$.components` with `isExternal: true`, because the requirement passes up to the artifact that contains the component. The `dependencies` graph still records the external requirement on the component that has it. So `isExternal: true` appears only in the top-level `$.components`, which lists, for the whole artifact, everything the environment has to provide. An external component may give the versions it accepts as a `versionRange` instead of a `version`. +Two parts that require the same package with different `version`s or `versionRange`s +keep one top-level entry each, with its own `bom-ref`, +and each part's `dependencies` edge points at its own; +requirements are never intersected or dropped. The case the contract exists for is content from another ecosystem. A JavaScript UI bundled into a WAR is an embedded component of the WAR, @@ -193,9 +209,13 @@ so the SBOM can be part of a reproducible-build comparison: - `serialNumber` is a name-based UUID (version 5) derived from the subject's `purl`, not a random one, - and `version` is `1`; + and a build-produced SBOM has `version` `1`; - `metadata.timestamp` is taken from `SOURCE_DATE_EPOCH`; -- arrays are sorted by `bom-ref`, +- every array of objects is sorted by a canonical key + (`bom-ref` for components, `ref` for `dependencies`, `alg` for `hashes`, + the SPDX identifier or name for `licenses`, `phase` for `metadata.lifecycles`, + `type` then `url` for `externalReferences`, `name` for `properties`), + every array of strings (such as `dependsOn`) is sorted lexically, and nothing host-specific (absolute paths, user names, tool cache locations) is recorded. The serial number departs from a recommendation of the specification, @@ -208,13 +228,13 @@ and a corrected SBOM for the same artifact keeps the serial number and increment ### 6. Distribution -An SBOM is published **next to its artifact, through the artifact's own channel**, +An SBOM is published **next to or inside its artifact, as the artifact's own channel dictates**, so a consumer finds it where they found the artifact. Each adapter declares the channel for its ecosystem (for example, the `cyclonedx` classifier on a Maven artifact, or the SBOM directory of a wheel's `.dist-info`). An artifact published to the project's download area carries its SBOM beside it, -with the same checksums and signature as the artifact. +with checksums and a signature of its own, made the same way as the artifact's. ### 7. Source releases @@ -243,9 +263,12 @@ No ecosystem's metadata records the license and copyright of individual files, but a project that follows its release policy already has that data: the release's `LICENSE` file names the bundled files under other licenses, and its `NOTICE` file carries the copyright attributions they require. -The source SBOM is their machine-readable counterpart, -produced from them and required to agree with them. -A project may also check them against the files' headers, +The source SBOM is their machine-readable counterpart. +Its inventory comes from the source tree, +since `NOTICE` carries attributions rather than paths +and a `LICENSE` that names a bundled library does not always list each of its files; +`LICENSE` and `NOTICE` are the cross-check the inventory must agree with. +A project finds the files from their headers, or with a license scanner of its choice; as for package SBOMs, the contract fixes what the source SBOM says, not which tool produces it. @@ -260,9 +283,9 @@ each adapter implements the ones that are ecosystem-specific. | `detect(checkout)` | yes | Finds the ecosystem's build files and the distributable artifacts they produce. | | `configure(artifact)` | yes | The one-time change to the build: adds and configures a generator of the project's choice, or configures the one it already uses. | | `generate(artifact)` | yes | Runs during the build and writes the artifact's SBOM. | -| `generate-source(release)` | no | Writes the source release's SBOM from its `LICENSE` and `NOTICE` files ([Source releases](#7-source-releases)). | -| `embed(container, part)` | no | Grafts the SBOM of embedded content into the container's SBOM ([Assemblies](#4-assemblies)): the part's embedded components are nested under the component that contains them, its external components are moved to the top-level `$.components` (merged with the ones already there), `bom-ref`s are kept unique, and the `dependencies` graphs are merged. | -| `normalize(sbom)` | no | Applies what generators do not: supplier, serial number, timestamp, ordering ([Reproducibility](#5-reproducibility)). | +| `generate-source(release)` | no | Writes the source release's SBOM from its source tree, checked against its `LICENSE` and `NOTICE` files ([Source releases](#7-source-releases)). | +| `embed(container, part)` | no | Grafts the SBOM of embedded content into the container's SBOM ([Assemblies](#4-assemblies)): the part's embedded components are nested under the component that contains them, its external components that the container embeds are pointed at the container's component, the rest are moved to the top-level `$.components` (one entry per distinct requirement), `bom-ref`s are kept unique, and the `dependencies` graphs are merged. | +| `normalize(sbom)` | no | Applies what generators do not: supplier, serial number, timestamp, deterministic `bom-ref`s, ordering, and the removal of `scope: excluded` components ([Reproducibility](#5-reproducibility)). | | `validate(sbom)` | no | Runs the [conformance checks](#conformance-checks). | | `publish(artifact, sbom)` | yes | Attaches the SBOM to the artifact through the ecosystem's channel ([Distribution](#6-distribution)). | @@ -270,7 +293,7 @@ each adapter implements the ones that are ecosystem-specific. and are implemented once, by a deterministic tool, not by each adapter. `generate-source` is the same for every ecosystem too: -it reads the release's `LICENSE` and `NOTICE`, +it reads the source tree and the release's `LICENSE` and `NOTICE`, which every release carries whatever its ecosystem. ## Conformance checks @@ -286,10 +309,11 @@ with the offending `bom-ref`: | C4 | Every component of an ecosystem with a package-URL type has a `purl`. | | C5 | Every `licenses` entry is a valid SPDX expression or SPDX license ID; a named license (`license.name`, not on the SPDX list) is reported for review. | | C6 | Every `bom-ref` named in `dependencies` exists in the document, and in a package SBOM every component appears in `dependencies`. | -| C7 | `serialNumber` is the version 5 UUID derived from the subject's `purl`, and `metadata.timestamp` matches `SOURCE_DATE_EPOCH`. | -| C8 | No nested component has `isExternal: true`, and every component that a nested component depends on in `dependencies` is either nested in the same assembly or external in the top-level `$.components`. | +| C7 | `serialNumber` is the version 5 UUID derived from the subject's `purl`, `metadata.timestamp` matches `SOURCE_DATE_EPOCH`, and every array is in the order rule 5 gives. | +| C8 | No nested component has `isExternal: true`, and every component that a nested component depends on in `dependencies` is nested in the same assembly or is in the top-level `$.components`. | | C9 | In a package SBOM, every embedded component in the top-level `$.components` has the same `purl` type as the subject. | | C10 | In a source SBOM, every file component has `hashes`, `licenses` and `copyright`, and every license other than the project's own appears in the release's `LICENSE` file. | +| C11 | Every `bom-ref` is the value rule 3 derives for its component, and no component has `scope: excluded`. | C9 and the `LICENSE` half of C10 are heuristics, reported for review, not as failures. An embedded component from another ecosystem at the top level (C9) @@ -354,6 +378,9 @@ Each adapter documents any ecosystem-specific keys it adds. A source release belongs to no package ecosystem, so which `purl` type identifies it in the source SBOM's `metadata.component` (and so derives its serial number) is not settled. + Until it is, rule 7 is provisional: + no source SBOM can meet rule 5's serial number, + so C7 is reported for review, not as a failure, on source SBOMs. - **Supplier configuration key.** Which key of the project or organization manifest names the supplier; an organization-level default (the ASF for every ASF project) diff --git a/tools/spec-loop/specs/README.md b/tools/spec-loop/specs/README.md index 1a9d087c4..845b1eb80 100644 --- a/tools/spec-loop/specs/README.md +++ b/tools/spec-loop/specs/README.md @@ -61,7 +61,8 @@ Start with [`overview.md`](overview.md), then: [`vetted-command-surface.md`](vetted-command-surface.md), [`adversarial-review.md`](adversarial-review.md), [`sandbox-diagnostics.md`](sandbox-diagnostics.md), - [`container-gateway.md`](container-gateway.md). + [`container-gateway.md`](container-gateway.md), + [`sbom-pipeline.md`](sbom-pipeline.md). (Agentic Autonomous, the fifth MISSION mode, is deliberately off and has no spec — see the note in [`overview.md`](overview.md).) diff --git a/tools/spec-loop/specs/overview.md b/tools/spec-loop/specs/overview.md index 1d0066d41..164547dcd 100644 --- a/tools/spec-loop/specs/overview.md +++ b/tools/spec-loop/specs/overview.md @@ -50,6 +50,7 @@ Each mode is an independently toggleable set of skills. Maturity mirrors | Agent isolation / layered sandbox | [agent-isolation-sandbox.md](agent-isolation-sandbox.md) | | Sandbox diagnostics — catalog, hint hook, doctor, verify | [sandbox-diagnostics.md](sandbox-diagnostics.md) | | Container gateway — podman / docker inside the sandbox | [container-gateway.md](container-gateway.md) | +| SBOM pipeline — one CycloneDX SBOM per artifact (proposed) | [sbom-pipeline.md](sbom-pipeline.md) | | CVE tooling | [cve-tooling.md](cve-tooling.md) | | Security reporting & dashboards | [security-reporting.md](security-reporting.md) | | Adoption & setup | [adoption-and-setup.md](adoption-and-setup.md) | diff --git a/tools/spec-loop/specs/sbom-pipeline.md b/tools/spec-loop/specs/sbom-pipeline.md new file mode 100644 index 000000000..ab1606975 --- /dev/null +++ b/tools/spec-loop/specs/sbom-pipeline.md @@ -0,0 +1,118 @@ + + +--- +title: SBOM pipeline (one CycloneDX SBOM per distributable artifact) +status: proposed +kind: feature +mode: infra +source: > + tools/sbom-pipeline/README.md (contract:sbom-pipeline, #1900). The + contract is written; no adapter, tool or skill implements it yet. +acceptance: + - Every distributable artifact, and the source release, has exactly one + CycloneDX 1.7 JSON SBOM of its own, complete without any other SBOM. + - Two builds of the same source produce byte-identical SBOMs. + - Content embedded from another ecosystem is described in the + container's SBOM as a nested assembly, never by reference. + - validate reports C1–C11 as passed, failed or for review, naming the + offending bom-ref. +--- + +## What it does + +Defines what a project's SBOMs must contain so that every distributable +artifact a project ships, whatever its ecosystem, carries one CycloneDX 1.7 +JSON SBOM describing exactly what is inside it and what it needs from its +environment, and so that the source release carries one describing the +files that do not share the project's terms. + +The contract fixes what the SBOMs say, not which generator writes them: +per-ecosystem adapters plug a project's existing generators into one +pipeline whose shared steps (`embed`, `normalize`, `validate`) are a single +deterministic tool. + +## Where it lives + +- `tools/sbom-pipeline/README.md` — the contract: rules 1–7, the + operations, conformance checks C1–C11, the planned adapters and + consumers, and the open questions. +- `contract:sbom-pipeline` is registered in + `tools/skill-and-tool-validator` (known capabilities), + `tools/vendor-neutrality-score` (`VENDOR_BACKED`, ❌ until two adapters + ship), `docs/labels-and-capabilities.md` and the generated + `.github/labeler.yml` rule. +- Planned, not present: `tools/sbom-pipeline-/` adapters + (maven, gradle, js, python, go, rust) and the `sbom-pipeline-integrate` + and `sbom-pipeline-verify` skills. + +## Behaviour & contract + +- **One SBOM per artifact.** + An artifact's SBOM is complete on its own; there is no combined SBOM + for a project. +- **Subject.** + `metadata.component` carries the artifact's `type`, `name`, `version`, + SPDX `licenses`, the project's configured `supplier`, and a `purl` in + the artifact's own ecosystem with the qualifiers that tell it apart from + the release's other artifacts, so no two artifacts share a subject + `purl`. +- **Components.** + Each has a deterministic `bom-ref` (its `purl`, else `name@version`, + prefixed by the containing component's `bom-ref` when nested), and the + `dependencies` graph covers every component. + Build- and test-only dependencies are left out; a generator that cannot + leave them out marks them `scope: excluded` and `normalize` removes them. +- **Assemblies.** + Embedded content is nested under the component that contains it. + A nested component's external requirement points at the artifact's own + top-level component when the artifact embeds it, and is otherwise + hoisted to the top level with `isExternal: true`, one entry per distinct + requirement. +- **Reproducibility.** + `serialNumber` is a version 5 UUID derived from the subject's `purl`, a + build-produced SBOM has `version` `1`, `metadata.timestamp` comes from + `SOURCE_DATE_EPOCH`, every array of objects is sorted by a canonical key + and every array of strings lexically, and nothing host-specific is + recorded. +- **Distribution.** + An SBOM is published next to or inside its artifact, as the artifact's + own channel dictates; one beside a download-area artifact carries its + own checksums and signature. +- **Source release.** + The source SBOM lists, as file components, every file whose license or + copyright differs from the project's own; its inventory comes from the + source tree and is cross-checked against `LICENSE` and `NOTICE`. + Rule 7 is provisional until the source release's `purl` type is settled. + +## Out of scope + +- Choosing a generator for a project: the adapters name examples only. +- Vulnerability data (VEX) and SPDX output. +- Publishing an aggregate SBOM for a whole project. + +## Acceptance criteria + +- The README defines rules 1–7, the eight operations and checks C1–C11, + each check reported as passed, failed or for review. +- `contract:sbom-pipeline` is a known capability in the validator, has a + vendor-neutrality row, and a labeler rule. +- No adapter claims `status: available` without a README declaring what + "Adding an adapter" requires. + +## Validation + +```bash +uv run --project tools/skill-and-tool-validator skill-and-tool-validate +uv run --project tools/vendor-neutrality-score vendor-neutrality-score --markdown +``` + +## Known gaps + +- **No adapter, tool or skill implements the contract yet.** + Every adapter and both consumer skills are `planned`. + +- **Open questions in the contract are unresolved.** + The source release's `purl` type, the supplier configuration key, and + where generator versions are recorded. + diff --git a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py index 149feb805..b606021ab 100644 --- a/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py +++ b/tools/vendor-neutrality-score/src/vendor_neutrality_score/__init__.py @@ -122,7 +122,7 @@ "Git-hook managers under the secure setup: install target and sandbox / host homes (prek, …)", ), "contract:sbom-pipeline": ( - AGNOSTIC, + VENDOR_BACKED, "SBOM generation in the build: one CycloneDX 1.7 SBOM per distributable artifact, any ecosystem", ), }