diff --git a/.agents/skills/sbom/SKILL.md b/.agents/skills/sbom/SKILL.md
index b08550739b..08b4d4c84e 100644
--- a/.agents/skills/sbom/SKILL.md
+++ b/.agents/skills/sbom/SKILL.md
@@ -13,17 +13,28 @@ The OpenShell SBOM tooling produces source-tree CycloneDX JSON SBOMs using Syft,
SBOMs are **release artifacts only** -- they are generated on demand and not committed to the repository. Output lands in `deploy/sbom/output/` (gitignored).
-Release Dev and Release Tag image builds separately embed cargo-auditable
-metadata in the staged gateway and supervisor binaries. This metadata describes
-the binary's Rust dependency graph and lets Syft discover Cargo packages from
-the binary itself. It is not a complete image SBOM and is not an OCI SBOM
-attestation; publishing such an attestation remains separate work.
+Pushed gateway and supervisor images carry an SPDX SBOM and minimal SLSA provenance as OCI attestations. Release Dev and Release Tag binaries also embed cargo-auditable metadata, so their image SBOMs include linked Rust crates.
## Prerequisites
- `mise install` has been run (installs Syft and other tools)
- The repository is checked out at the root
+## Inspecting an Image SBOM
+
+BuildKit uses its default Syft scanner and attaches one SPDX document per platform. Read one without pulling the image:
+
+```bash
+docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest \
+ --format '{{ json (index .SBOM "linux/amd64").SPDX }}'
+```
+
+Validate the final attestation, requiring a Cargo package for an auditable image:
+
+```bash
+tasks/scripts/verify-image-sbom.sh ghcr.io/nvidia/openshell/gateway:latest --require-cargo
+```
+
## Inspecting an Auditable Image Binary
Opt into auditable metadata when staging a local image binary:
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 2e7abe2008..3563e667a5 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -294,6 +294,10 @@ jobs:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ inputs['checkout-ref'] || github.sha }}
+
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
@@ -309,3 +313,6 @@ jobs:
--prefer-index=false \
-t "${image}:${{ needs.resolve.outputs.image_tag_base }}" \
"${refs[@]}"
+
+ - name: Verify merged manifest SBOM attestation
+ run: tasks/scripts/verify-image-sbom.sh "ghcr.io/nvidia/openshell/${{ inputs.component }}:${{ needs.resolve.outputs.image_tag_base }}" ${{ inputs.auditable && '--require-cargo' || '' }}
diff --git a/architecture/build.md b/architecture/build.md
index 6f6272398c..4bc266bfb3 100644
--- a/architecture/build.md
+++ b/architecture/build.md
@@ -137,8 +137,8 @@ binary. That section holds data rather than symbols, so it survives the
workspace's `strip = true` release profile, and Syft can catalog the crates
present in image binaries instead of inferring them from the source tree. This
is a different artifact from the source SBOM produced by `syft dir:.` in
-`tasks/sbom.toml`, which describes the checkout, and from an OCI SBOM
-attestation, which remains out of scope.
+`tasks/sbom.toml`, which describes the checkout, and from the image SBOM
+attestation below, which describes a published image.
Only release image builds are auditable. `docker-build.yml` and
`rust-native-build.yml` take an `auditable` input that defaults to false, so PR
@@ -151,6 +151,16 @@ be misidentified as `rustc`. Auditable builds are verified by scanning the built
binary with Syft and requiring at least one decoded Cargo package; the check runs
only for those builds.
+Pushed Docker images carry minimal SLSA provenance and a per-platform SPDX SBOM
+generated by BuildKit's default Syft scanner. The registry exporter uses OCI
+media types and `oci-artifact=true`, so each attestation identifies its subject.
+GHCR exposes these through the image index because it has no referrers API.
+
+Attestations require a registry-backed image index. Local builds therefore keep
+`--provenance=false`, and Podman builds carry neither attestation.
+`tasks/scripts/verify-image-sbom.sh` verifies the merged multi-arch tag and also
+requires Cargo packages for auditable Release Dev and Release Tag images.
+
Runtime layout:
- **Gateway**: `gcr.io/distroless/cc-debian13:nonroot` base, GNU-linked binary at
diff --git a/docs/security/verifying-images.mdx b/docs/security/verifying-images.mdx
new file mode 100644
index 0000000000..4b9704d3b5
--- /dev/null
+++ b/docs/security/verifying-images.mdx
@@ -0,0 +1,36 @@
+---
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+title: "Verify the Contents of Published OpenShell Images"
+sidebar-title: "Verify Image Contents"
+slug: "security/verify-image-contents"
+description: "Read the SBOM attestation attached to published gateway and supervisor images to audit what each image contains."
+keywords: "Generative AI, Cybersecurity, Supply Chain, SBOM, Container Images"
+position: 2
+---
+
+Published gateway and supervisor images carry one SPDX SBOM per platform as OCI attestations.
+
+## Inspect an Image
+
+Read a platform's document without pulling the image:
+
+```shell
+docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest --format '{{ json (index .SBOM "linux/amd64").SPDX }}'
+```
+
+List the packages instead of the full document:
+
+```shell
+docker buildx imagetools inspect ghcr.io/nvidia/openshell/gateway:latest --format '{{ range (index .SBOM "linux/amd64").SPDX.packages }}{{ .name }}@{{ .versionInfo }}{{ println }}{{ end }}'
+```
+
+The same commands work for `ghcr.io/nvidia/openshell/supervisor`.
+
+## Coverage
+
+Every SBOM lists the base-image packages. Release Dev and Release Tag images also list the Rust crates compiled into their OpenShell binary.
+
+
+OpenShell also publishes minimal SLSA provenance. It records how BuildKit produced the image, including its source revision, build platform, and base-image materials, without the extra build parameters included by full provenance.
+
diff --git a/tasks/scripts/docker-build-image.sh b/tasks/scripts/docker-build-image.sh
index 8570180f12..08ba00e066 100755
--- a/tasks/scripts/docker-build-image.sh
+++ b/tasks/scripts/docker-build-image.sh
@@ -168,14 +168,19 @@ if [[ "${IS_FINAL_IMAGE}" == "1" ]]; then
TAG_ARGS=(-t "${IMAGE_NAME}:${IMAGE_TAG}")
fi
+ATTESTATION_ARGS=(--provenance=false)
OUTPUT_ARGS=()
if [[ -n "${DOCKER_OUTPUT:-}" ]]; then
OUTPUT_ARGS=(--output "${DOCKER_OUTPUT}")
elif [[ "${IS_FINAL_IMAGE}" == "1" ]]; then
- if [[ "${DOCKER_PUSH:-}" == "1" ]]; then
- OUTPUT_ARGS=(--push)
- elif [[ "${DOCKER_PLATFORM:-}" == *","* ]]; then
- OUTPUT_ARGS=(--push)
+ if [[ "${DOCKER_PUSH:-}" == "1" || "${DOCKER_PLATFORM:-}" == *","* ]]; then
+ if ce_is_docker; then
+ # Attestations require a registry-backed image index.
+ ATTESTATION_ARGS=(--provenance=mode=min --attest type=sbom)
+ OUTPUT_ARGS=(--output "type=image,push=true,oci-mediatypes=true,oci-artifact=true")
+ else
+ OUTPUT_ARGS=(--push)
+ fi
else
OUTPUT_ARGS=(--load)
fi
@@ -191,7 +196,7 @@ ce_build \
-f "${DOCKERFILE}" \
--target "${DOCKER_TARGET}" \
${TAG_ARGS[@]+"${TAG_ARGS[@]}"} \
- --provenance=false \
+ ${ATTESTATION_ARGS[@]+"${ATTESTATION_ARGS[@]}"} \
"$@" \
${OUTPUT_ARGS[@]+"${OUTPUT_ARGS[@]}"} \
.
diff --git a/tasks/scripts/verify-image-sbom.sh b/tasks/scripts/verify-image-sbom.sh
new file mode 100755
index 0000000000..1703b0608a
--- /dev/null
+++ b/tasks/scripts/verify-image-sbom.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+set -euo pipefail
+
+# Validate the final image's SBOM attestation and, for auditable builds, require
+# at least one Cargo package discovered from the binary metadata.
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+source "${SCRIPT_DIR}/container-engine.sh"
+
+usage() {
+ echo "Usage: verify-image-sbom.sh [--require-cargo]" >&2
+}
+
+IMAGE=${1:-}
+REQUIRE_CARGO=${2:-}
+if [[ -z "${IMAGE}" || $# -gt 2 || ( -n "${REQUIRE_CARGO}" && "${REQUIRE_CARGO}" != "--require-cargo" ) ]]; then
+ usage
+ exit 2
+fi
+
+if ! ce_is_docker; then
+ echo "Error: SBOM attestations are produced on the Docker/buildx path; ${CONTAINER_ENGINE} has no imagetools equivalent" >&2
+ exit 2
+fi
+
+echo "==> Inspecting SBOM attestation of ${IMAGE}"
+SBOM_JSON="$(ce buildx imagetools inspect "${IMAGE}" --format '{{ json .SBOM }}')"
+COUNTS="$(
+ jq -r '
+ [.. | objects | .SPDX? | select(type == "object")] as $documents
+ | [
+ ($documents | length),
+ ([$documents[] | .. | strings | select(startswith("pkg:cargo/"))] | length)
+ ]
+ | @tsv
+ ' <<<"${SBOM_JSON}"
+)"
+read -r SPDX_COUNT CARGO_COUNT <<<"${COUNTS}"
+
+if [[ "${SPDX_COUNT}" -eq 0 ]]; then
+ echo "Error: ${IMAGE} carries no SPDX SBOM" >&2
+ exit 1
+fi
+if [[ "${REQUIRE_CARGO}" == "--require-cargo" && "${CARGO_COUNT}" -eq 0 ]]; then
+ echo "Error: ${IMAGE} SBOM contains no Cargo packages" >&2
+ exit 1
+fi
+
+echo "SBOM attestation verified: SPDX=${SPDX_COUNT}, Cargo=${CARGO_COUNT}"