From a50dce1afcec0bd707f653124011cd88099337f2 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:32:45 -0700 Subject: [PATCH 1/2] feat: publish release SBOMs Generate SPDX JSON SBOMs for every raw rad binary with a pinned, checksum-verified Syft release, and publish them as additive GitHub Release assets without changing the existing binary checksum contract. Enable BuildKit SBOM attestations on every GoReleaser production image and require one valid SPDX document for each locked platform before finalization. Recheck CLI and image SBOMs when reconciling an already published release. Classify the seven CLI SBOM files as intentional parity additions, retain them with GoReleaser workflow metadata, and document how to find both release assets and per-platform image attestations. Refs: #12818 Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .cspellignore | 1 + .github/scripts/release-assets.mjs | 56 ++++++ .github/scripts/release-assets_test.mjs | 99 ++++++++++ .github/scripts/release-oci-artifacts.sh | 73 +++++++- .github/scripts/release-oci-artifacts_test.sh | 85 ++++++++- .github/scripts/release-parity-manifest.sh | 9 +- .../scripts/release-parity-manifest_test.sh | 6 + .github/scripts/release-sboms_test.sh | 124 +++++++++++++ .github/scripts/verify-goreleaser-snapshot.sh | 103 ++++++++++- .../verify-goreleaser-snapshot_test.sh | 52 +++++- .github/workflows/build-release.yaml | 47 ++++- .github/workflows/goreleaser-snapshot.yaml | 5 +- .goreleaser.yaml | 28 ++- build/scripts/install-syft.sh | 169 ++++++++++++++++++ build/test.mk | 6 +- build/tools.generated.mk | 6 + build/tools.mk | 10 ++ build/tools.yaml | 26 +++ .../contributing-releases/README.md | 14 ++ 19 files changed, 899 insertions(+), 20 deletions(-) create mode 100644 .github/scripts/release-sboms_test.sh create mode 100755 build/scripts/install-syft.sh diff --git a/.cspellignore b/.cspellignore index 56ccb69fdc..2cd0719433 100644 --- a/.cspellignore +++ b/.cspellignore @@ -1345,3 +1345,4 @@ syft repoints korthout uncheckpointed +anchore diff --git a/.github/scripts/release-assets.mjs b/.github/scripts/release-assets.mjs index cb8a475295..ed43abbe24 100644 --- a/.github/scripts/release-assets.mjs +++ b/.github/scripts/release-assets.mjs @@ -65,6 +65,34 @@ function findAsset(assets, name) { return matches[0]; } +/** @param {Buffer} data @param {string} name */ +function verifySpdxDocument(data, name) { + let document; + try { + document = JSON.parse(data.toString("utf8")); + } catch (error) { + throw new Error(`Release SBOM ${name} is not valid JSON`, { cause: error }); + } + const creators = document?.creationInfo?.creators; + if ( + typeof document !== "object" || + document === null || + !/^SPDX-2\.\d+$/.test(document.spdxVersion) || + document.SPDXID !== "SPDXRef-DOCUMENT" || + document.dataLicense !== "CC0-1.0" || + typeof document.documentNamespace !== "string" || + !document.documentNamespace.startsWith("https://") || + typeof document.creationInfo?.created !== "string" || + !Array.isArray(creators) || + !creators.some((creator) => /^Tool: syft-/.test(creator)) || + !Array.isArray(document.packages) || + document.packages.length === 0 || + !Array.isArray(document.relationships) + ) { + throw new Error(`Release SBOM ${name} is not a valid SPDX document`); + } +} + async function uploadAssetData(github, owner, repo, release, name, data) { let response; let assets; @@ -268,6 +296,30 @@ async function reconcileCliAssets( core.setOutput("verified_assets", String(names.length)); } +/** @param {any} github @param {any} core @param {string} owner @param {string} repo @param {any} release */ +async function verifySbomAssets(github, core, owner, repo, release) { + const targetsFile = core.getInput("TARGETS_FILE", { required: true }); + const targets = JSON.parse(await readFile(targetsFile, "utf8")); + const cliAssets = /** @type {{name: string}[]} */ (targets.cliAssets); + const names = cliAssets.map((asset) => `${asset.name}.sbom.json`).sort(); + const assets = /** @type {{id: number, name: string}[]} */ ( + await listAssets(github, owner, repo, release.id) + ); + const actual = assets + .map((asset) => asset.name) + .filter((name) => name.endsWith(".sbom.json")) + .sort(); + if (JSON.stringify(actual) !== JSON.stringify(names)) { + throw new Error("Release SBOM assets do not match the expected set"); + } + for (const name of names) { + const asset = findAsset(assets, name); + const data = await downloadAsset(github, owner, repo, asset.id); + verifySpdxDocument(data, name); + } + core.setOutput("verified_sboms", String(names.length)); +} + /** @param {{github: any, core: any}} options */ export default async function releaseAssets({ github, core }) { const owner = core.getInput("OWNER", { required: true }); @@ -292,5 +344,9 @@ export default async function releaseAssets({ github, core }) { await reconcileCliAssets(github, core, owner, repo, release, true); return; } + if (mode === "verify-sboms") { + await verifySbomAssets(github, core, owner, repo, release); + return; + } throw new Error(`Unsupported release asset mode: ${mode}`); } diff --git a/.github/scripts/release-assets_test.mjs b/.github/scripts/release-assets_test.mjs index 8f9d568756..b21d0adff8 100644 --- a/.github/scripts/release-assets_test.mjs +++ b/.github/scripts/release-assets_test.mjs @@ -51,6 +51,22 @@ function fixture({ draft = true, assets = [] } = {}) { return { calls, core, github, inputs, outputs }; } +function spdxDocument(overrides = {}) { + return JSON.stringify({ + spdxVersion: "SPDX-2.3", + SPDXID: "SPDXRef-DOCUMENT", + dataLicense: "CC0-1.0", + documentNamespace: "https://anchore.com/syft/file/rad-test", + creationInfo: { + created: "2026-08-28T00:00:00Z", + creators: ["Organization: Anchore, Inc", "Tool: syft-1.51.0"] + }, + packages: [{ SPDXID: "SPDXRef-Package-radius", name: "radius" }], + relationships: [], + ...overrides + }); +} + test("downloads exact release assets", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-")); try { @@ -242,6 +258,89 @@ test("verifies release binaries against split checksums", async () => { } }); +test("verifies the exact release SBOM set as SPDX JSON", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-")); + try { + const targets = path.join(root, "targets.json"); + await writeFile(targets, '{"cliAssets":[{"name":"rad_linux_amd64"}]}'); + const state = fixture({ + assets: [ + { + id: 1, + name: "rad_linux_amd64.sbom.json", + contents: spdxDocument() + } + ] + }); + Object.assign(state.inputs, { + OWNER: "radius-project", + REPO: "radius", + TAG: "v0.61.0", + MODE: "verify-sboms", + TARGETS_FILE: targets + }); + await releaseAssets(state); + assert.equal(state.outputs.verified_sboms, "1"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects a malformed release SBOM", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-")); + try { + const targets = path.join(root, "targets.json"); + await writeFile(targets, '{"cliAssets":[{"name":"rad_linux_amd64"}]}'); + const state = fixture({ + assets: [ + { + id: 1, + name: "rad_linux_amd64.sbom.json", + contents: spdxDocument({ packages: [] }) + } + ] + }); + Object.assign(state.inputs, { + OWNER: "radius-project", + REPO: "radius", + TAG: "v0.61.0", + MODE: "verify-sboms", + TARGETS_FILE: targets + }); + await assert.rejects(() => releaseAssets(state), /valid SPDX document/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("rejects an unexpected release SBOM asset", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-")); + try { + const targets = path.join(root, "targets.json"); + await writeFile(targets, '{"cliAssets":[{"name":"rad_linux_amd64"}]}'); + const state = fixture({ + assets: [ + { + id: 1, + name: "rad_linux_amd64.sbom.json", + contents: spdxDocument() + }, + { id: 2, name: "unexpected.sbom.json", contents: spdxDocument() } + ] + }); + Object.assign(state.inputs, { + OWNER: "radius-project", + REPO: "radius", + TAG: "v0.61.0", + MODE: "verify-sboms", + TARGETS_FILE: targets + }); + await assert.rejects(() => releaseAssets(state), /expected set/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("normalizes a native GoReleaser split checksum on a draft", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "release-assets-")); try { diff --git a/.github/scripts/release-oci-artifacts.sh b/.github/scripts/release-oci-artifacts.sh index 2bdab7d9ca..8c5139d655 100644 --- a/.github/scripts/release-oci-artifacts.sh +++ b/.github/scripts/release-oci-artifacts.sh @@ -41,6 +41,7 @@ OUTPUT="" CATEGORIES="production,non-go,test" NAMES="" VERIFY_ALIASES=false +VERIFY_SBOMS=false PROMOTE_LATEST="${RELEASE_PROMOTE_LATEST:-true}" SOURCE_SHA="${RELEASE_SOURCE_SHA:-}" TEMP_DIR="" @@ -149,7 +150,7 @@ Usage: release-oci-artifacts.sh verify --version \ [--channel ] [--image-lock ] [--cli-lock ] \ [--categories ] [--names ] \ - [--source-sha ] [--aliases] + [--source-sha ] [--aliases] [--sboms] release-oci-artifacts.sh assert-images-absent --registry \ --version [--categories ] [--names ] \ [--source-sha ] @@ -214,6 +215,10 @@ parse_args() { VERIFY_ALIASES=true shift ;; + --sboms) + VERIFY_SBOMS=true + shift + ;; -h | --help) usage exit 0 @@ -660,6 +665,52 @@ verify_cli_alias() { fi } +is_production_image() { + local name="$1" + + jq -e --arg name "${name}" ' + any(.images[]; + .name == $name + and .category == "production" + and .radiusBuild == true) + ' "${TARGETS_FILE}" > /dev/null +} + +verify_image_sboms() { + local immutable_reference="$1" + local platforms="$2" + local sboms + + if ! sboms="$(retry_read "image SBOM lookup" \ + docker buildx imagetools inspect \ + --format '{{json .SBOM}}' "${immutable_reference}")"; then + fail "cannot inspect image SBOMs: ${immutable_reference}" + fi + if ! jq -e --argjson platforms "${platforms}" ' + . as $sboms + | type == "object" + and all($platforms[]; + . as $platform + | $sboms[$platform].SPDX as $document + | ($document | type == "object") + and ($document.spdxVersion + | type == "string" and test("^SPDX-2\\.[0-9]+$")) + and $document.SPDXID == "SPDXRef-DOCUMENT" + and $document.dataLicense == "CC0-1.0" + and ($document.documentNamespace + | type == "string" and startswith("https://")) + and ($document.creationInfo.created + | type == "string" and length > 0) + and any($document.creationInfo.creators[]?; + startswith("Tool: syft-")) + and ($document.packages | type == "array" and length > 0) + and ($document.relationships | type == "array") + ) + ' <<< "${sboms}" > /dev/null; then + fail "image has missing or invalid SPDX SBOMs: ${immutable_reference}" + fi +} + image_aliases_match() { local repository="$1" local channel="$2" @@ -802,6 +853,8 @@ verify_locks() { local repository local digest local immutable_reference + local name + local platforms require_command jq validate_version @@ -822,6 +875,9 @@ verify_locks() { if [[ "${VERIFY_ALIASES}" == "true" && -z "${CHANNEL}" ]]; then fail "channel is required when verifying aliases" fi + if [[ "${VERIFY_SBOMS}" == "true" && -z "${IMAGE_LOCK}" ]]; then + fail "image lock is required when verifying SBOMs" + fi if [[ -n "${IMAGE_LOCK}" ]]; then require_command docker @@ -855,7 +911,8 @@ verify_locks() { fail "image lock source does not match the release source" fi - while IFS=$'\t' read -r reference digest immutable_reference; do + while IFS=$'\t' read -r name reference digest immutable_reference \ + platforms; do if [[ "${reference}" != *":${VERSION}" ]]; then fail "image lock has wrong version: ${reference}" fi @@ -863,6 +920,15 @@ verify_locks() { fail "image lock has an invalid immutable reference" fi verify_image_alias "${reference}" "${digest}" + if [[ "${VERIFY_SBOMS}" == "true" ]]; then + if is_production_image "${name}"; then + if ! jq -e 'type == "array" and length > 0' \ + <<< "${platforms}" > /dev/null; then + fail "image lock has no platforms for ${name}" + fi + verify_image_sboms "${immutable_reference}" "${platforms}" + fi + fi if [[ "${VERIFY_ALIASES}" == "true" ]]; then repository="${immutable_reference%@*}" verify_image_alias "${repository}:${CHANNEL}" "${digest}" @@ -871,7 +937,8 @@ verify_locks() { fi fi done < <(jq -r '.[] | - [.reference, .digest, .immutableReference] | @tsv + [.name, .reference, .digest, .immutableReference, + (.platforms | tojson)] | @tsv ' "${IMAGE_LOCK}") fi diff --git a/.github/scripts/release-oci-artifacts_test.sh b/.github/scripts/release-oci-artifacts_test.sh index ada952ac5b..cc4f36339b 100644 --- a/.github/scripts/release-oci-artifacts_test.sh +++ b/.github/scripts/release-oci-artifacts_test.sh @@ -220,7 +220,13 @@ if [[ "$1 $2 $3" == "buildx imagetools inspect" ]]; then echo "error getting credentials" >&2 exit 1 fi - digest="$(awk -F '\t' -v ref="${reference}" '$1 == ref { value=$2 } END { print value }' "${state}")" + if [[ "${reference}" == *@sha256:* ]]; then + digest="${reference##*@}" + grep -Fq "${digest}" "${state}" || digest="" + else + digest="$(awk -F '\t' -v ref="${reference}" \ + '$1 == ref { value=$2 } END { print value }' "${state}")" + fi if [[ "${reference}" == "${FAKE_CORRUPT_REFERENCE:-}" ]]; then digest="sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" fi @@ -228,6 +234,42 @@ if [[ "$1 $2 $3" == "buildx imagetools inspect" ]]; then echo "manifest not found" >&2 exit 1 fi + if [[ "$*" == *"{{json .SBOM}}"* ]]; then + if [[ "${FAKE_SBOM_MODE:-}" == "absent" ]]; then + printf 'null\n' + exit 0 + fi + packages='[{"SPDXID":"SPDXRef-Package-radius","name":"radius"}]' + if [[ "${FAKE_SBOM_MODE:-}" == "malformed" ]]; then + packages='[]' + fi + platforms=(linux/amd64 linux/arm64 linux/arm/v7) + if [[ "${FAKE_SBOM_MODE:-}" == "partial" ]]; then + platforms=(linux/amd64 linux/arm64) + fi + printf '{' + for index in "${!platforms[@]}"; do + [[ "${index}" -eq 0 ]] || printf ',' + cat < /dev/null 2>&1; then + fail_test "${mode} image SBOM should fail verification" + return + fi + done + ((++PASS)) +} + test_retries_transient_registry_failures() { setup_fixture PATH="${TEST_ROOT}/bin:${PATH}" \ @@ -640,6 +717,8 @@ main() { test_detects_alias_divergence test_detects_version_tag_divergence test_rejects_lock_from_another_source + test_verifies_image_sboms + test_rejects_missing_or_malformed_image_sboms test_retries_transient_registry_failures test_rejects_stale_cli_tag_without_overwriting test_requires_image_lock_before_reusing_version_tag diff --git a/.github/scripts/release-parity-manifest.sh b/.github/scripts/release-parity-manifest.sh index 6ca176438e..8cb758b157 100644 --- a/.github/scripts/release-parity-manifest.sh +++ b/.github/scripts/release-parity-manifest.sh @@ -221,6 +221,7 @@ collect_cli_assets() { local checksum_size local build_info_path local expected_assets + local expected_sboms local allowed_assets local actual_assets @@ -231,9 +232,13 @@ collect_cli_assets() { | .name, (.name + ".sha256") ] | sort' "${TARGETS_FILE}" )" + expected_sboms="$(jq -c '[ + .cliAssets[] | .name + ".sbom.json" + ] | sort' "${TARGETS_FILE}")" allowed_assets="$(jq -c -n \ - --argjson expected "${expected_assets}" ' - ($expected + [ + --argjson expected "${expected_assets}" \ + --argjson sboms "${expected_sboms}" ' + ($expected + $sboms + [ "bicep-image-digests.json", "bicep-image-intent.json", "core-release-lock.json", diff --git a/.github/scripts/release-parity-manifest_test.sh b/.github/scripts/release-parity-manifest_test.sh index 5d1910d431..76f3d3cae4 100644 --- a/.github/scripts/release-parity-manifest_test.sh +++ b/.github/scripts/release-parity-manifest_test.sh @@ -421,6 +421,12 @@ jq '.assets += [{"name":"core-release-lock.json"}]' \ mv "${FIXTURES}/release-lock.json" "${FIXTURES}/release.json" run_collector +printf '{}\n' >"${ASSETS}/rad_linux_amd64.sbom.json" +jq '.assets += [{"name":"rad_linux_amd64.sbom.json"}]' \ + "${FIXTURES}/release.json" >"${FIXTURES}/release-sbom.json" +mv "${FIXTURES}/release-sbom.json" "${FIXTURES}/release.json" +run_collector + printf 'unexpected\n' >"${ASSETS}/unexpected.txt" jq '.assets += [{"name":"unexpected.txt"}]' \ "${FIXTURES}/release.json" >"${FIXTURES}/release-extra.json" diff --git a/.github/scripts/release-sboms_test.sh b/.github/scripts/release-sboms_test.sh new file mode 100644 index 0000000000..6e52c7b394 --- /dev/null +++ b/.github/scripts/release-sboms_test.sh @@ -0,0 +1,124 @@ +#!/bin/bash + +# ------------------------------------------------------------ +# Copyright 2026 The Radius Authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# ------------------------------------------------------------ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +readonly REPO_ROOT +readonly WORKFLOWS_DIR="${REPO_ROOT}/.github/workflows" +readonly RELEASE_WORKFLOW="${WORKFLOWS_DIR}/build-release.yaml" +readonly SNAPSHOT_WORKFLOW="${WORKFLOWS_DIR}/goreleaser-snapshot.yaml" +readonly TOOLS_MANIFEST="${REPO_ROOT}/build/tools.yaml" +readonly TOOLS_MAKEFILE="${REPO_ROOT}/build/tools.generated.mk" +readonly RELEASE_DOCS_DIR="${REPO_ROOT}/docs/contributing" +readonly RELEASE_DOC="${RELEASE_DOCS_DIR}/contributing-releases/README.md" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +verify_tool_pin() { + local syft + local version + + syft="$(yq -o=json '.tools[] | select(.name == "syft")' \ + "${TOOLS_MANIFEST}")" + jq -e ' + .makePrefix == "SYFT" + and .source.type == "github-release" + and .source.repository == "anchore/syft" + and .checksumSource.type == "github-release-file" + and .checksumSource.format == "standard" + and (.platforms | keys | sort) + == ["darwin_amd64", "darwin_arm64", + "linux_amd64", "linux_arm64"] + and all(.platforms[].checksum; test("^[0-9a-f]{64}$")) + ' <<< "${syft}" > /dev/null || fail "Syft tool pin is incomplete" + version="$(jq -r '.version' <<< "${syft}")" + if ! grep -Fqx "SYFT_VERSION ?= ${version}" "${TOOLS_MAKEFILE}"; then + fail "generated Syft version is not synchronized" + fi +} + +verify_workflow_wiring() { + local asset_verifiers + local image_verifiers + local metadata_uploads + + if ! grep -Fq 'install-goreleaser install-syft' \ + "${SNAPSHOT_WORKFLOW}"; then + fail "snapshot workflow does not install Syft" + fi + if ! grep -Fq 'make install-goreleaser install-syft' \ + "${RELEASE_WORKFLOW}"; then + fail "release workflow does not install Syft" + fi + asset_verifiers="$(grep -Fc 'INPUT_MODE: verify-sboms' \ + "${RELEASE_WORKFLOW}")" + if [[ "${asset_verifiers}" != "2" ]]; then + fail "draft and published CLI SBOM verification is incomplete" + fi + image_verifiers="$(grep -Fc -- '--sboms' "${RELEASE_WORKFLOW}")" + if [[ "${image_verifiers}" != "3" ]]; then + fail "image SBOM verification is missing from a release path" + fi + metadata_uploads="$({ + grep -F 'dist/goreleaser/*.sbom.json' "${SNAPSHOT_WORKFLOW}" + grep -F 'dist/goreleaser/*.sbom.json' "${RELEASE_WORKFLOW}" + } | wc -l)" + if [[ "${metadata_uploads}" != "2" ]]; then + fail "SBOMs are missing from retained GoReleaser metadata" + fi +} + +verify_documentation() { + if ! grep -Fq 'rad_linux_amd64.sbom.json' "${RELEASE_DOC}"; then + fail "CLI SBOM discovery is not documented" + fi + if ! grep -Fq '{{ json (index .SBOM "linux/amd64").SPDX }}' \ + "${RELEASE_DOC}"; then + fail "image SBOM discovery is not documented" + fi + if ! grep -Fq 'SPDX 2' "${RELEASE_DOC}"; then + fail "SBOM format is not documented" + fi +} + +main() { + command -v jq > /dev/null + command -v yq > /dev/null + + bash "${SCRIPT_DIR}/verify-goreleaser-snapshot.sh" --config-only \ + > /dev/null + yq -e ' + (.checksum.ids | length) == 1 + and .checksum.ids[0] == "rad" + and .sboms[0].id == "rad-sbom" + ' "${REPO_ROOT}/.goreleaser.yaml" > /dev/null || { + fail "SBOMs can leak into the binary checksum pipeline" + } + verify_tool_pin + verify_workflow_wiring + verify_documentation + echo "release SBOM contract tests passed" +} + +main "$@" diff --git a/.github/scripts/verify-goreleaser-snapshot.sh b/.github/scripts/verify-goreleaser-snapshot.sh index efe23f0152..2523b6245d 100644 --- a/.github/scripts/verify-goreleaser-snapshot.sh +++ b/.github/scripts/verify-goreleaser-snapshot.sh @@ -103,13 +103,58 @@ verify_native_checksum_config() { fail "native GoReleaser checksum configuration is not enabled" } +verify_sbom_config() { + local expected_artifact="\${artifact}" + local expected_document="spdx-json=\${document}" + + EXPECTED_ARTIFACT="${expected_artifact}" \ + EXPECTED_DOCUMENT="${expected_document}" yq -e ' + (.sboms | length) == 1 + and .sboms[0].id == "rad-sbom" + and .sboms[0].artifacts == "binary" + and (.sboms[0].ids | length) == 1 + and .sboms[0].ids[0] == "rad" + and (.sboms[0].documents | length) == 1 + and .sboms[0].documents[0] + == "{{ .ArtifactName }}.sbom.json" + and .sboms[0].cmd == "syft" + and (.sboms[0].args | length) == 5 + and .sboms[0].args[0] == strenv(EXPECTED_ARTIFACT) + and .sboms[0].args[1] == "--output" + and .sboms[0].args[2] == strenv(EXPECTED_DOCUMENT) + and .sboms[0].args[3] == "--enrich" + and .sboms[0].args[4] == "golang" + and ([.dockers_v2[] | select(.sbom != true)] | length) == 0 + ' "${CONFIG_FILE}" >/dev/null || + fail "GoReleaser SBOM settings do not match the release contract" +} + +verify_spdx_json() { + local file="$1" + + jq -e ' + type == "object" + and (.spdxVersion + | type == "string" and test("^SPDX-2\\.[0-9]+$")) + and .SPDXID == "SPDXRef-DOCUMENT" + and .dataLicense == "CC0-1.0" + and (.documentNamespace + | type == "string" and startswith("https://")) + and (.creationInfo.created | type == "string" and length > 0) + and any(.creationInfo.creators[]?; startswith("Tool: syft-")) + and (.packages | type == "array" and length > 0) + and (.relationships | type == "array") + ' "${file}" >/dev/null || fail "invalid SPDX JSON SBOM: ${file}" +} + verify_release_config() { local global_environment local expected_disable='{{ .Env.GORELEASER_RELEASE_DISABLE }}' yq -e ' - ((.release.ids | length) == 1) + ((.release.ids | length) == 2) and (.release.ids[0] == "rad") + and (.release.ids[1] == "rad-sbom") and (.release.draft == true) and (.release.use_existing_draft == true) and (.release.replace_existing_artifacts == true) @@ -198,6 +243,58 @@ verify_cli_assets() { done < <(jq -r '.cliAssets[].name' "${TARGETS_FILE}") } +verify_cli_sboms() { + local artifacts_file="$1" + local expected_names + local actual_names + local unexpected_checksums + local name + local artifact_path + + expected_names="$(jq -c '[ + .cliAssets[] | .name + ".sbom.json" + ] | sort' "${TARGETS_FILE}")" + actual_names="$(jq -c '[ + .[] + | select(.type == "SBOM" and .extra.ID == "rad-sbom") + | .name + ] | sort' "${artifacts_file}")" + assert_json_equal "${actual_names}" "${expected_names}" \ + "CLI SBOM asset names" + unexpected_checksums="$(jq -c '[ + .[] + | select( + .type == "Checksum" + and (.extra.ChecksumOf // "" | endswith(".sbom.json")) + ) + | .name + ]' "${artifacts_file}")" + [[ "${unexpected_checksums}" == "[]" ]] || + fail "SBOM checksum sidecars change the release asset contract" + + while IFS= read -r name; do + artifact_path="$(jq -er --arg name "${name}" ' + [ + .[] + | select( + .name == $name + and .type == "SBOM" + and .extra.ID == "rad-sbom" + ) + ] + | select(length == 1) + | .[0].path + ' "${artifacts_file}")" + if [[ "${artifact_path}" != /* ]]; then + artifact_path="${REPO_ROOT}/${artifact_path}" + fi + [[ -f "${artifact_path}" ]] || + fail "missing CLI SBOM: ${artifact_path}" + verify_spdx_json "${artifact_path}" + done < <(jq -r '.cliAssets[] | .name + ".sbom.json"' \ + "${TARGETS_FILE}") +} + verify_build_matrix() { local mode="${1:-}" local expected_builds @@ -291,7 +388,7 @@ verify_image_definitions() { and (.ids[0] == strenv(IMAGE)) and ((.tags | length) == 1) and (.tags[0] == "{{ .Version }}") - and (.sbom == false) + and (.sbom == true) and (.labels."org.opencontainers.image.description" == strenv(IMAGE)) and (.labels."org.opencontainers.image.source" @@ -464,6 +561,7 @@ main() { require_command yq verify_native_checksum_config + verify_sbom_config verify_release_config verify_build_matrix --config-only verify_image_definitions @@ -478,6 +576,7 @@ main() { [[ -f "${DIST_DIR}/artifacts.json" ]] || fail "missing GoReleaser artifacts metadata" verify_cli_assets "${DIST_DIR}/artifacts.json" + verify_cli_sboms "${DIST_DIR}/artifacts.json" verify_build_matrix if [[ "${SKIP_IMAGES}" -eq 1 ]]; then echo "skipping built image verification: the snapshot ran without Docker" diff --git a/.github/scripts/verify-goreleaser-snapshot_test.sh b/.github/scripts/verify-goreleaser-snapshot_test.sh index 4e8cd27dc5..94106bf4e3 100644 --- a/.github/scripts/verify-goreleaser-snapshot_test.sh +++ b/.github/scripts/verify-goreleaser-snapshot_test.sh @@ -61,10 +61,34 @@ CORRUPT_CHECKSUM="" # write a wrong hash into this asset's sidecar EXTRA_TARGET="false" # add a rad build for a platform outside the contract OMIT_PLATFORM="" # : to leave out of the built images WITHOUT_IMAGES="false" # emulate a snapshot that ran with --skip=docker +OMIT_SBOM="" # drop this CLI asset's SBOM from the metadata +INVALID_SBOM="" # write a document that is not SPDX for this CLI asset + +# The smallest document that satisfies the verifier's SPDX checks. +write_sbom() { + local asset="$1" + local path="$2" + + jq -n --arg asset "${asset}" ' + { + spdxVersion: "SPDX-2.3", + SPDXID: "SPDXRef-DOCUMENT", + dataLicense: "CC0-1.0", + name: $asset, + documentNamespace: ("https://radius.example/spdx/" + $asset), + creationInfo: { + created: "2026-09-10T00:00:00Z", + creators: ["Tool: syft-1.0.0"] + }, + packages: [{SPDXID: "SPDXRef-Package-rad", name: "rad"}], + relationships: [] + } + ' >"${path}" +} write_cli_fixture() { local asset os arch goarm - local binary_dir binary_path hash sidecar + local binary_dir binary_path hash sidecar sbom while IFS=$'\t' read -r asset os arch; do [[ "${asset}" != "${OMIT_ASSET}" ]] || continue @@ -103,6 +127,22 @@ write_cli_fixture() { extra: {ChecksumOf: $checksum_of} } ' >>"${ENTRIES}" + + [[ "${asset}" != "${OMIT_SBOM}" ]] || continue + sbom="${DIST}/${asset}.sbom.json" + if [[ "${asset}" == "${INVALID_SBOM}" ]]; then + printf '{"name":"%s"}\n' "${asset}" >"${sbom}" + else + write_sbom "${asset}" "${sbom}" + fi + jq -n -c --arg name "${asset}.sbom.json" --arg path "${sbom}" ' + { + name: $name, + path: $path, + type: "SBOM", + extra: {ID: "rad-sbom"} + } + ' >>"${ENTRIES}" done < <(jq -r '.cliAssets[] | [.name, .os, .arch] | @tsv' "${TARGETS}") if [[ "${EXTRA_TARGET}" == "true" ]]; then @@ -233,6 +273,16 @@ write_fixture expect_failure "a production image missing a required platform" OMIT_PLATFORM="" +OMIT_SBOM="rad_darwin_arm64" +write_fixture +expect_failure "a CLI asset without an SBOM" +OMIT_SBOM="" + +INVALID_SBOM="rad_linux_amd64" +write_fixture +expect_failure "a CLI SBOM that is not an SPDX document" +INVALID_SBOM="" + # Configuration drift is caught before any artifact is inspected. The copy is # exploded first so editing one image cannot silently follow a YAML anchor. write_fixture diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index d9c7d53a73..d6be8e9562 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -154,6 +154,23 @@ jobs: ) await script({ github, core }) + - name: Verify completed release SBOM assets + if: steps.release-state.outputs.result == 'published' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_OWNER: ${{ github.repository_owner }} + INPUT_REPO: radius + INPUT_TAG: ${{ github.ref_name }} + INPUT_MODE: verify-sboms + INPUT_TARGETS_FILE: .github/release-parity/targets.json + with: + retries: 5 + script: | + const { default: script } = await import( + `${process.env.GITHUB_WORKSPACE}/.github/scripts/release-assets.mjs` + ) + await script({ github, core }) + - name: Verify completed release metadata if: steps.release-state.outputs.result == 'published' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -188,6 +205,7 @@ jobs: --source-sha "${SOURCE_SHA}" --image-lock dist/preflight/release-image-digests.json --cli-lock dist/preflight/release-cli-oci.json + --sboms ) bash ./.github/scripts/release-oci-artifacts.sh "${args[@]}" @@ -244,7 +262,7 @@ jobs: - name: Install release tools run: >- - make install-goreleaser install-jq install-yq install-oras + make install-goreleaser install-syft install-jq install-yq install-oras - name: Ensure source-bound draft release uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -422,6 +440,15 @@ jobs: DOCKER_REGISTRY: ${{ env.CONTAINER_REGISTRY }} DOCKER_TAG_VERSION: ${{ env.REL_VERSION }} + - name: Verify production image SBOMs + run: | + bash ./.github/scripts/release-oci-artifacts.sh verify \ + --version "${REL_VERSION}" \ + --source-sha "${SOURCE_SHA}" \ + --categories production \ + --image-lock dist/production-image-digests.json \ + --sboms + - name: Persist the production image lock if: steps.image-lock.outputs.locked != 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -456,6 +483,22 @@ jobs: ) await script({ github, core }) + - name: Verify staged CLI SBOM assets + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + INPUT_OWNER: ${{ github.repository_owner }} + INPUT_REPO: radius + INPUT_TAG: ${{ github.ref_name }} + INPUT_MODE: verify-sboms + INPUT_TARGETS_FILE: .github/release-parity/targets.json + with: + retries: 5 + script: | + const { default: script } = await import( + `${process.env.GITHUB_WORKSPACE}/.github/scripts/release-assets.mjs` + ) + await script({ github, core }) + - name: Download staged CLI binaries if: steps.cli-lock.outputs.locked != 'true' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 @@ -557,6 +600,7 @@ jobs: dist/goreleaser/artifacts.json dist/goreleaser/metadata.json dist/goreleaser/*.sha256 + dist/goreleaser/*.sbom.json dist/goreleaser/rad_*/rad dist/goreleaser/rad_*/rad.exe if-no-files-found: error @@ -916,6 +960,7 @@ jobs: --source-sha "${SOURCE_SHA}" --image-lock dist/release-image-digests.json --cli-lock dist/release-cli-oci.json + --sboms ) if [[ "${UPDATE_RELEASE:-}" == "true" ]]; then args+=(--channel "${REL_CHANNEL}") diff --git a/.github/workflows/goreleaser-snapshot.yaml b/.github/workflows/goreleaser-snapshot.yaml index 28de2989e3..b7e19fb70d 100644 --- a/.github/workflows/goreleaser-snapshot.yaml +++ b/.github/workflows/goreleaser-snapshot.yaml @@ -77,8 +77,8 @@ jobs: - name: Install comparison tools run: make install-jq install-yq - - name: Install GoReleaser - run: make install-goreleaser + - name: Install release tools + run: make install-goreleaser install-syft - name: Check configuration run: make goreleaser-check @@ -95,5 +95,6 @@ jobs: dist/goreleaser/artifacts.json dist/goreleaser/metadata.json dist/goreleaser/*.sha256 + dist/goreleaser/*.sbom.json if-no-files-found: warn retention-days: 7 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7b3d03d78e..2718a67ac7 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -105,6 +105,23 @@ checksum: ids: - rad +sboms: + - id: rad-sbom + artifacts: binary + ids: + - rad + documents: + - "{{ .ArtifactName }}.sbom.json" + cmd: syft + # Narrower than GoReleaser's 'all' default: rad is a static Go binary, so + # this bounds enrichment to the Go module cache, vendor dir, and proxy. + args: + - "${artifact}" + - --output + - "spdx-json=${document}" + - --enrich + - golang + dockers_v2: - id: ucpd ids: @@ -125,7 +142,7 @@ dockers_v2: - linux/amd64 - linux/arm64 - linux/arm/v7 - sbom: false + sbom: true - id: applications-rp ids: @@ -141,7 +158,7 @@ dockers_v2: org.opencontainers.image.version: "{{ .Version }}" org.opencontainers.image.revision: "{{ .FullCommit }}" platforms: *image_platforms - sbom: false + sbom: true - id: dynamic-rp ids: @@ -157,7 +174,7 @@ dockers_v2: org.opencontainers.image.version: "{{ .Version }}" org.opencontainers.image.revision: "{{ .FullCommit }}" platforms: *image_platforms - sbom: false + sbom: true - id: controller ids: @@ -173,7 +190,7 @@ dockers_v2: org.opencontainers.image.version: "{{ .Version }}" org.opencontainers.image.revision: "{{ .FullCommit }}" platforms: *image_platforms - sbom: false + sbom: true - id: pre-upgrade ids: @@ -189,11 +206,12 @@ dockers_v2: org.opencontainers.image.version: "{{ .Version }}" org.opencontainers.image.revision: "{{ .FullCommit }}" platforms: *image_platforms - sbom: false + sbom: true release: ids: - rad + - rad-sbom disable: "{{ .Env.GORELEASER_RELEASE_DISABLE }}" name_template: "Radius {{ .Tag }}" draft: true diff --git a/build/scripts/install-syft.sh b/build/scripts/install-syft.sh new file mode 100755 index 0000000000..abf6e90125 --- /dev/null +++ b/build/scripts/install-syft.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Installs Syft into a user-owned directory for the current platform. + +readonly REPO="anchore/syft" +readonly RELEASES_URL="https://github.com/${REPO}/releases" + +WORKDIR="" + +log() { echo "[install-syft] $*" >&2; } +fail() { + echo "[install-syft] ERROR: $*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "${WORKDIR:-}" && -d "${WORKDIR}" ]]; then + rm -rf "${WORKDIR}" + fi +} + +gh_curl() { + local headers=(-H "User-Agent: syft-installer") + if [[ -n "${GITHUB_TOKEN:-}" ]]; then + headers+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") + fi + curl --proto '=https' --tlsv1.2 --retry 5 --retry-connrefused \ + "${headers[@]}" "$@" +} + +resolve_latest_version() { + local effective_url + effective_url="$( + gh_curl -fsSLI -o /dev/null -w '%{url_effective}' \ + "${RELEASES_URL}/latest" + )" || fail "could not resolve the latest Syft version" + printf '%s\n' "${effective_url##*/tag/}" +} + +checksum_from_release() { + local version="$1" + local asset="$2" + local version_no_v="${version#v}" + local checksum_url + + checksum_url="${RELEASES_URL}/download/${version}/" + checksum_url+="syft_${version_no_v}_checksums.txt" + if ! gh_curl -fsSL "${checksum_url}" \ + -o "${WORKDIR}/checksums.txt"; then + fail "could not download checksums for ${version}" + fi + awk -v asset="${asset}" '$2 == asset { print $1 }' \ + "${WORKDIR}/checksums.txt" +} + +verify_checksum() { + local expected="$1" + local file="$2" + + if command -v sha256sum > /dev/null 2>&1; then + echo "${expected} ${file}" | sha256sum -c - > /dev/null + elif command -v shasum > /dev/null 2>&1; then + echo "${expected} ${file}" | shasum -a 256 -c - > /dev/null + else + fail "neither sha256sum nor shasum is available" + fi +} + +installed_version() { + syft version 2> /dev/null | awk '/^Version:/ { print $2; exit }' +} + +main() { + local install_dir + local os + local arch + local platform + local version + local version_no_v + local asset + local checksum + local actual_version + local version_output + + command -v curl > /dev/null 2>&1 || fail "curl is required" + command -v tar > /dev/null 2>&1 || fail "tar is required" + + install_dir="${1:-${SYFT_INSTALL_DIR:-}}" + [[ -n "${install_dir}" ]] || install_dir="${HOME}/.local/bin" + + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + *) + fail "unsupported OS '$(uname -s)' (supported: Linux, Darwin)" + ;; + esac + case "$(uname -m)" in + x86_64 | amd64) arch="amd64" ;; + aarch64 | arm64) arch="arm64" ;; + *) fail "unsupported architecture '$(uname -m)'" ;; + esac + platform="${os}_${arch}" + + version="${SYFT_VERSION:-}" + version="${version//[[:space:]]/}" + if [[ -z "${version}" ]]; then + log "resolving latest Syft version..." + version="$(resolve_latest_version)" + elif [[ "${version}" =~ ^[0-9] ]]; then + version="v${version}" + fi + [[ -n "${version}" ]] || fail "could not determine the version to install" + + if command -v syft > /dev/null 2>&1; then + if [[ "$(installed_version)" == "${version#v}" ]]; then + log "Syft ${version} already installed: $(command -v syft)" + return 0 + fi + fi + + version_no_v="${version#v}" + asset="syft_${version_no_v}_${os}_${arch}.tar.gz" + case "${platform}" in + linux_amd64) checksum="${SYFT_CHECKSUM_LINUX_AMD64:-}" ;; + linux_arm64) checksum="${SYFT_CHECKSUM_LINUX_ARM64:-}" ;; + darwin_amd64) checksum="${SYFT_CHECKSUM_DARWIN_AMD64:-}" ;; + darwin_arm64) checksum="${SYFT_CHECKSUM_DARWIN_ARM64:-}" ;; + *) fail "unsupported platform '${platform}'" ;; + esac + + WORKDIR="$(mktemp -d)" + if [[ -z "${checksum}" ]]; then + log "reading ${asset} checksum from the ${version} release..." + checksum="$(checksum_from_release "${version}" "${asset}")" + fi + if [[ ! "${checksum}" =~ ^[0-9a-f]{64}$ ]]; then + fail "could not determine the SHA-256 checksum for ${asset}" + fi + + log "downloading ${asset} ${version}..." + gh_curl -fsSL "${RELEASES_URL}/download/${version}/${asset}" \ + -o "${WORKDIR}/${asset}" || fail "could not download ${asset}" + verify_checksum "${checksum}" "${WORKDIR}/${asset}" + if ! tar -xzf "${WORKDIR}/${asset}" -C "${WORKDIR}"; then + fail "could not extract ${asset}" + fi + [[ -f "${WORKDIR}/syft" ]] || fail "expected syft binary not found" + + chmod 0755 "${WORKDIR}/syft" + mkdir -p "${install_dir}" + mv "${WORKDIR}/syft" "${install_dir}/syft" + version_output="$("${install_dir}/syft" version 2> /dev/null)" + actual_version="$(awk '/^Version:/ { print $2; exit }' \ + <<< "${version_output}")" + if [[ "${actual_version}" != "${version_no_v}" ]]; then + fail "installed Syft version does not match ${version}" + fi + log "installed Syft ${version} to ${install_dir}/syft" + + if [[ -n "${GITHUB_PATH:-}" ]]; then + echo "${install_dir}" >> "${GITHUB_PATH}" + fi +} + +trap cleanup EXIT +main "$@" diff --git a/build/test.mk b/build/test.mk index b4a5183b56..b990b3ad1c 100644 --- a/build/test.mk +++ b/build/test.mk @@ -53,7 +53,7 @@ GOTEST_OPTS ?= GOTEST_TOOL ?= go tool gotestsum $(GOTESTSUM_OPTS) -- .PHONY: test -test: test-get-envtools test-helm test-manage-radius-installation test-release-parity-manifest test-verify-goreleaser-snapshot test-changelog-range test-changelog-config test-build-summary test-capture-release-image-digests test-release-get-version test-release-tag-and-branch test-monitor-remote-workflow test-release-version-format test-prepare-release test-release-plan test-release-backport test-release-branch-commits test-release-cutover test-release-oci-artifacts ## Runs unit tests, excluding kubernetes controller tests +test: test-get-envtools test-helm test-manage-radius-installation test-release-parity-manifest test-verify-goreleaser-snapshot test-changelog-range test-changelog-config test-build-summary test-capture-release-image-digests test-release-get-version test-release-tag-and-branch test-monitor-remote-workflow test-release-version-format test-prepare-release test-release-plan test-release-backport test-release-branch-commits test-release-cutover test-release-oci-artifacts test-release-sboms ## Runs unit tests, excluding kubernetes controller tests KUBEBUILDER_ASSETS="$(shell $(ENV_SETUP) use -p path ${K8S_VERSION} --arch amd64)" CGO_ENABLED=1 $(GOTEST_TOOL) ./pkg/... ./test/validation/... $(GOTEST_OPTS) .PHONY: test-manage-radius-installation @@ -117,6 +117,10 @@ test-release-cutover: ## Tests the GoReleaser tag cutover workflow contract test-release-oci-artifacts: install-oras ## Tests immutable OCI staging and alias promotion @bash ./.github/scripts/release-oci-artifacts_test.sh +.PHONY: test-release-sboms +test-release-sboms: ## Tests release SBOM generation and verification wiring + @bash ./.github/scripts/release-sboms_test.sh + .PHONY: test-release-tag-and-branch test-release-tag-and-branch: ## Tests release tag and branch reconciliation @bash ./.github/scripts/release-create-tag-and-branch_test.sh diff --git a/build/tools.generated.mk b/build/tools.generated.mk index b47d41d910..175e5e6b85 100644 --- a/build/tools.generated.mk +++ b/build/tools.generated.mk @@ -54,6 +54,12 @@ GORELEASER_CHECKSUM_LINUX_ARM64 ?= 93dba7614308e167158bd26978e8275971fd4b9147e7f GORELEASER_CHECKSUM_DARWIN_AMD64 ?= 623e9ba517ace49c3d6b57bcfe8f5fe33ca45313ee93261c1854464cca94d861 GORELEASER_CHECKSUM_DARWIN_ARM64 ?= 8e912c5cc78896d791b7530e672d4a4ef9c00ebff7375de410fae1b459825ea3 +SYFT_VERSION ?= v1.51.0 +SYFT_CHECKSUM_LINUX_AMD64 ?= 2a2e837a2c8d59ec9af5472ee22d3b04ee463c4e44476ecf993fd1e5ab6ebc7f +SYFT_CHECKSUM_LINUX_ARM64 ?= 6c0466811541ea03add5213a60a1562f0851e4c0b0ecfdee1a694a9455285900 +SYFT_CHECKSUM_DARWIN_AMD64 ?= cddf9a044145caf0a1a3194d00d1dd51a1666f4814f2919cdb4768a0c062ad95 +SYFT_CHECKSUM_DARWIN_ARM64 ?= 4f37f4c7fefce0a68e4cf71ba3f5f9829a99e65d89b29f7ee41b8c2c10ea8c59 + GIT_CLIFF_VERSION ?= v2.14.1 GIT_CLIFF_CHECKSUM_LINUX_AMD64 ?= dfe9bef0c7a00d05fafe78e6e591a1a280d39dd495b8066764db0a559acde67f GIT_CLIFF_CHECKSUM_LINUX_ARM64 ?= e99dc84135c6bc27aefc9d8226f7297119ed6750a32f45418f4587bc6cb260d8 diff --git a/build/tools.mk b/build/tools.mk index 7045250b21..a09c6c205c 100644 --- a/build/tools.mk +++ b/build/tools.mk @@ -132,6 +132,16 @@ install-goreleaser: ## Install the pinned GoReleaser CLI into a user-owned bin d GORELEASER_INSTALL_DIR="$(GORELEASER_INSTALL_DIR)" \ ./build/scripts/install-goreleaser.sh +.PHONY: install-syft +install-syft: ## Install the pinned Syft SBOM generator into a user-owned bin dir (no sudo). + @SYFT_VERSION="$(SYFT_VERSION)" \ + SYFT_CHECKSUM_LINUX_AMD64="$(SYFT_CHECKSUM_LINUX_AMD64)" \ + SYFT_CHECKSUM_LINUX_ARM64="$(SYFT_CHECKSUM_LINUX_ARM64)" \ + SYFT_CHECKSUM_DARWIN_AMD64="$(SYFT_CHECKSUM_DARWIN_AMD64)" \ + SYFT_CHECKSUM_DARWIN_ARM64="$(SYFT_CHECKSUM_DARWIN_ARM64)" \ + SYFT_INSTALL_DIR="$(SYFT_INSTALL_DIR)" \ + ./build/scripts/install-syft.sh + .PHONY: install-git-cliff install-git-cliff: ## Install the pinned git-cliff CLI into a user-owned bin dir (no sudo). @GIT_CLIFF_VERSION="$(GIT_CLIFF_VERSION)" \ diff --git a/build/tools.yaml b/build/tools.yaml index 0236292917..2cd7afa4c5 100644 --- a/build/tools.yaml +++ b/build/tools.yaml @@ -243,6 +243,32 @@ tools: fileTemplate: checksums.txt format: standard + - name: syft + makePrefix: SYFT + version: v1.51.0 + source: + type: github-release + repository: anchore/syft + latestURL: https://api.github.com/repos/anchore/syft/releases/latest + downloadTemplate: https://github.com/{repository}/releases/download/{tag}/{asset} + platforms: + linux_amd64: + asset: syft_{version_no_v}_linux_amd64.tar.gz + checksum: 2a2e837a2c8d59ec9af5472ee22d3b04ee463c4e44476ecf993fd1e5ab6ebc7f + linux_arm64: + asset: syft_{version_no_v}_linux_arm64.tar.gz + checksum: 6c0466811541ea03add5213a60a1562f0851e4c0b0ecfdee1a694a9455285900 + darwin_amd64: + asset: syft_{version_no_v}_darwin_amd64.tar.gz + checksum: cddf9a044145caf0a1a3194d00d1dd51a1666f4814f2919cdb4768a0c062ad95 + darwin_arm64: + asset: syft_{version_no_v}_darwin_arm64.tar.gz + checksum: 4f37f4c7fefce0a68e4cf71ba3f5f9829a99e65d89b29f7ee41b8c2c10ea8c59 + checksumSource: + type: github-release-file + fileTemplate: syft_{version_no_v}_checksums.txt + format: standard + - name: git-cliff makePrefix: GIT_CLIFF version: v2.14.1 diff --git a/docs/contributing/contributing-releases/README.md b/docs/contributing/contributing-releases/README.md index 606f006441..ee90b754b5 100644 --- a/docs/contributing/contributing-releases/README.md +++ b/docs/contributing/contributing-releases/README.md @@ -81,6 +81,20 @@ Four GitHub Actions workflows drive release preparation, backports, and publicat Finalization is serialized across release versions. A patch to an older supported channel updates that channel without replacing global `latest`; an older version finishing after a newer version in the same channel changes neither alias. Builds remain parallel, and alias promotion uses the recorded digests without rebuilding artifacts. + #### Release SBOMs + + Each raw `rad` binary has an SPDX 2.x JSON SBOM beside it on the GitHub Release, named by adding `.sbom.json` to the binary asset name. For example, `rad_linux_amd64.sbom.json` describes `rad_linux_amd64`. The release workflow generates these documents with the pinned Syft version and verifies their structure before publication. SBOM assets deliberately carry no `.sha256` sidecar, so the split checksum contract still covers exactly the `rad` binaries. + + Production image SBOMs are SPDX JSON predicates in per-platform BuildKit attestations attached to the immutable full-version OCI image index. BuildKit generates them with its own bundled scanner during the image build, so they are independent of the pinned Syft used for the CLI assets. They are not duplicate GitHub Release assets. Inspect one by immutable digest with Docker Buildx: + + ```bash + docker buildx imagetools inspect \ + "ghcr.io/radius-project/ucpd@sha256:" \ + --format '{{ json (index .SBOM "linux/amd64").SPDX }}' + ``` + + The release workflow requires a valid attestation for every published production-image platform before it locks the image digest or publishes the GitHub Release. + The automated flow after dispatching Prepare Release: ```text From 91ccc7781ba43ddd07d28432f710e13c9d567680 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:49:38 -0700 Subject: [PATCH 2/2] feat: update Syft to v1.51.1 with new checksums and add SBOM generation review notes Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- build/tools.generated.mk | 10 +++--- build/tools.yaml | 10 +++--- .../2026-09-goreleaser-stack-review/README.md | 1 + .../pr-14-goreleaser-sboms.md | 31 +++++++++++++++++++ 4 files changed, 42 insertions(+), 10 deletions(-) create mode 100644 eng/design-notes/tools/2026-09-goreleaser-stack-review/pr-14-goreleaser-sboms.md diff --git a/build/tools.generated.mk b/build/tools.generated.mk index 175e5e6b85..a88498e844 100644 --- a/build/tools.generated.mk +++ b/build/tools.generated.mk @@ -54,11 +54,11 @@ GORELEASER_CHECKSUM_LINUX_ARM64 ?= 93dba7614308e167158bd26978e8275971fd4b9147e7f GORELEASER_CHECKSUM_DARWIN_AMD64 ?= 623e9ba517ace49c3d6b57bcfe8f5fe33ca45313ee93261c1854464cca94d861 GORELEASER_CHECKSUM_DARWIN_ARM64 ?= 8e912c5cc78896d791b7530e672d4a4ef9c00ebff7375de410fae1b459825ea3 -SYFT_VERSION ?= v1.51.0 -SYFT_CHECKSUM_LINUX_AMD64 ?= 2a2e837a2c8d59ec9af5472ee22d3b04ee463c4e44476ecf993fd1e5ab6ebc7f -SYFT_CHECKSUM_LINUX_ARM64 ?= 6c0466811541ea03add5213a60a1562f0851e4c0b0ecfdee1a694a9455285900 -SYFT_CHECKSUM_DARWIN_AMD64 ?= cddf9a044145caf0a1a3194d00d1dd51a1666f4814f2919cdb4768a0c062ad95 -SYFT_CHECKSUM_DARWIN_ARM64 ?= 4f37f4c7fefce0a68e4cf71ba3f5f9829a99e65d89b29f7ee41b8c2c10ea8c59 +SYFT_VERSION ?= v1.51.1 +SYFT_CHECKSUM_LINUX_AMD64 ?= 8fcb33017a0dc1058298c923c436d19dfa68ae93968e0b423248542e3afb9fc3 +SYFT_CHECKSUM_LINUX_ARM64 ?= a7fd2b784e6664acd44719270574f6cd8c6864fc2b1700bf9099bd1cccda7d7f +SYFT_CHECKSUM_DARWIN_AMD64 ?= 0e186ce1d4351ec276126851ca3ff258ed070e93e73574ed64858d4fc2339867 +SYFT_CHECKSUM_DARWIN_ARM64 ?= ac063af3b9874769deb7ea1e6d76841e68f9e3bb50cd654226fc977de65532c1 GIT_CLIFF_VERSION ?= v2.14.1 GIT_CLIFF_CHECKSUM_LINUX_AMD64 ?= dfe9bef0c7a00d05fafe78e6e591a1a280d39dd495b8066764db0a559acde67f diff --git a/build/tools.yaml b/build/tools.yaml index 2cd7afa4c5..16763ce456 100644 --- a/build/tools.yaml +++ b/build/tools.yaml @@ -245,7 +245,7 @@ tools: - name: syft makePrefix: SYFT - version: v1.51.0 + version: v1.51.1 source: type: github-release repository: anchore/syft @@ -254,16 +254,16 @@ tools: platforms: linux_amd64: asset: syft_{version_no_v}_linux_amd64.tar.gz - checksum: 2a2e837a2c8d59ec9af5472ee22d3b04ee463c4e44476ecf993fd1e5ab6ebc7f + checksum: 8fcb33017a0dc1058298c923c436d19dfa68ae93968e0b423248542e3afb9fc3 linux_arm64: asset: syft_{version_no_v}_linux_arm64.tar.gz - checksum: 6c0466811541ea03add5213a60a1562f0851e4c0b0ecfdee1a694a9455285900 + checksum: a7fd2b784e6664acd44719270574f6cd8c6864fc2b1700bf9099bd1cccda7d7f darwin_amd64: asset: syft_{version_no_v}_darwin_amd64.tar.gz - checksum: cddf9a044145caf0a1a3194d00d1dd51a1666f4814f2919cdb4768a0c062ad95 + checksum: 0e186ce1d4351ec276126851ca3ff258ed070e93e73574ed64858d4fc2339867 darwin_arm64: asset: syft_{version_no_v}_darwin_arm64.tar.gz - checksum: 4f37f4c7fefce0a68e4cf71ba3f5f9829a99e65d89b29f7ee41b8c2c10ea8c59 + checksum: ac063af3b9874769deb7ea1e6d76841e68f9e3bb50cd654226fc977de65532c1 checksumSource: type: github-release-file fileTemplate: syft_{version_no_v}_checksums.txt diff --git a/eng/design-notes/tools/2026-09-goreleaser-stack-review/README.md b/eng/design-notes/tools/2026-09-goreleaser-stack-review/README.md index d99b88004b..dd6f4d916e 100644 --- a/eng/design-notes/tools/2026-09-goreleaser-stack-review/README.md +++ b/eng/design-notes/tools/2026-09-goreleaser-stack-review/README.md @@ -19,6 +19,7 @@ This directory records the review of the 18-pull-request stack that implements t | 11 | `dp/dotted-rc-identifiers` | [pr-11-dotted-rc-identifiers.md](./pr-11-dotted-rc-identifiers.md) | | 12 | `dp/prepare-release-backports` | [pr-12-prepare-release-backports.md](./pr-12-prepare-release-backports.md) | | 13 | `dp/goreleaser-tag-cutover` | [pr-13-goreleaser-tag-cutover.md](./pr-13-goreleaser-tag-cutover.md) | +| 14 | `dp/goreleaser-sboms` | [pr-14-goreleaser-sboms.md](./pr-14-goreleaser-sboms.md) | Later notes are added as the review progresses up the stack. diff --git a/eng/design-notes/tools/2026-09-goreleaser-stack-review/pr-14-goreleaser-sboms.md b/eng/design-notes/tools/2026-09-goreleaser-stack-review/pr-14-goreleaser-sboms.md new file mode 100644 index 0000000000..407f454c2f --- /dev/null +++ b/eng/design-notes/tools/2026-09-goreleaser-stack-review/pr-14-goreleaser-sboms.md @@ -0,0 +1,31 @@ +# Review note: PR 14 - SBOM generation + +- **Pull request**: [#12869](https://github.com/radius-project/radius/pull/12869) +- **Plan phase**: [PR 14](../2026-03-goreleaser-release-lifecycle-implementation-plan.md#pr-14-sbom-generation) +- **Stack index**: [README](./README.md) + +## Verdict + +The layer is the additive follow-up the plan asks for, and it is careful in the two places where an SBOM addition can quietly change a release. GoReleaser's native Syft integration produces one SPDX 2.x JSON document per raw `rad` binary, attached to the draft release under the binary's name plus `.sbom.json`, and the checksum pipeline stays restricted to the binaries, so the seven `.sha256` sidecars remain exactly the published contract; the verifier fails if a checksum for an SBOM ever appears. The five production images gain BuildKit SBOM attestations through `dockers_v2`, and every path that trusts an image lock now also requires one valid SPDX document per locked platform: after staging, at finalization, and when a published release is reconciled. The parity collector classifies the seven CLI documents as explained additions. Syft is pinned in the tool manifest with per-platform checksums verified against the upstream checksum file, installed by a script that mirrors the GoReleaser installer, and the SBOM contract suite pins the configuration, the workflow wiring, the tool pin, and the documentation together. Enrichment is narrowed to Go module data, which is the right scope for a static Go binary. The runbook explains where each kind of SBOM lives and how to read an image attestation by digest. All suites pass, `goreleaser check` validates the configuration, the generated tool metadata matches the manifest, and ShellCheck, Prettier, markdownlint, and cspell are clean. + +## Changes made in this review + +### 1. Syft pinned to the current patch release + +- **What changed**: the manifest pins Syft v1.51.1 with the four upstream checksums, and the generated Make metadata is regenerated from it. +- **Why**: v1.51.1 shipped on 27 August, fourteen days before this review and past the manifest's seven-day cooldown, so the tool updater would have proposed it anyway. It fixes the Go remote license search, which this configuration enables through `--enrich golang`, so that standard-library modules are no longer looked up as if they were dependencies, and it remediates three vulnerabilities in Syft's own dependencies, two of them rated high. +- **Value**: the first release with SBOMs uses a scanner without a known defect in the exact code path the configuration exercises. +- **Impact**: none on the generated document structure; the contract suite only requires a Syft creator, and the installer verifies the new checksums. + +## Findings left as-is + +- **Network use during snapshots**: `--enrich golang` lets Syft query the Go module proxy for licenses, so every pull request snapshot now performs remote lookups for seven binaries. The runs stay well inside their budgets and the data is worth having; noted so a future timeout is not misread. +- **Image attestation scanner**: BuildKit generates the image SBOMs with its own bundled scanner, independent of the pinned Syft, which the runbook says explicitly. Pinning that scanner would mean a `generator` image reference in every `dockers_v2` entry; deferred until a reason appears. +- **Draft gate**: the pull request stays in draft until an RC draft release shows all seven CLI documents and valid attestations on every production platform, which is the plan's exit criterion. + +## Verification + +- Shell: SBOM contract, OCI artifacts (15, including absent, partial, and malformed attestations), snapshot verifier, parity manifest, cutover (8), and digest capture suites pass; ShellCheck is clean for the eight new or changed scripts including the installer. +- Node: release assets (15), draft release (4), and publication (9) suites pass. +- `goreleaser check` validates the configuration in the real checkout; the tool updater regenerates identical Make metadata from the manifest; every pinned checksum, before and after the bump, matches the upstream checksum file; the installer downloads and verifies v1.51.1. +- actionlint reports only the `queue` key it does not know yet; Prettier with the repository configuration passes for the workflows and the Node scripts; markdownlint and cspell pass for the runbook and these notes.