From f6d21f8b1c1a6f310a515225294db1802cd5d955 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/4] 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 6213be714c2..a4a565a8af8 100644 --- a/.cspellignore +++ b/.cspellignore @@ -1350,3 +1350,4 @@ syft repoints korthout uncheckpointed +anchore diff --git a/.github/scripts/release-assets.mjs b/.github/scripts/release-assets.mjs index fcf3d68256a..03ffe83221e 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 deleteStarterAsset(github, owner, repo, release, asset) { if (asset?.state !== "starter") { return false; @@ -299,6 +327,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 }); @@ -323,5 +375,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 15a0e327171..4055aa0b06d 100644 --- a/.github/scripts/release-assets_test.mjs +++ b/.github/scripts/release-assets_test.mjs @@ -77,6 +77,22 @@ function fixture({ draft = true, assets = [], starterFailures = 0 } = {}) { 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 { @@ -379,6 +395,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 2bdab7d9ca6..8c5139d6556 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 ada952ac5b3..cc4f36339be 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 6ca176438e2..8cb758b1572 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 5d1910d4319..76f3d3cae48 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 00000000000..6e52c7b394b --- /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 2a11ed55704..040203ec0ab 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 @@ -295,7 +392,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" @@ -468,6 +565,7 @@ main() { require_command yq verify_native_checksum_config + verify_sbom_config verify_release_config verify_build_matrix --config-only verify_image_definitions @@ -482,6 +580,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 eebb57d464e..c2ef5e672c8 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 @@ -293,6 +333,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 8e4ffd1aec5..db3e026b2cf 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[@]}" @@ -247,7 +265,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 @@ -425,6 +443,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 @@ -459,6 +486,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 @@ -560,6 +603,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 @@ -922,6 +966,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 bf6391947b9..a8018d740fd 100644 --- a/.github/workflows/goreleaser-snapshot.yaml +++ b/.github/workflows/goreleaser-snapshot.yaml @@ -80,8 +80,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 @@ -98,5 +98,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 7b3d03d78e9..2718a67ac72 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 00000000000..abf6e901250 --- /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 0bffee1f1d3..689af20795d 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 @@ -121,6 +121,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 193127a3a85..b06bb853755 100644 --- a/build/tools.generated.mk +++ b/build/tools.generated.mk @@ -54,6 +54,12 @@ GORELEASER_CHECKSUM_LINUX_ARM64 ?= a71681b29194f08f057a68cfcaa5c6b15d907a83a2622 GORELEASER_CHECKSUM_DARWIN_AMD64 ?= 5e97d6517f73a0b6f71675a2911b15d3136c03c8907650c2b18e114b1c0f5205 GORELEASER_CHECKSUM_DARWIN_ARM64 ?= a811ff154fe136a0cfb55d00126c151fc39ec370a663d805a9ca5547445aa70c +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.2 GIT_CLIFF_CHECKSUM_LINUX_AMD64 ?= 24f397c733add5390fdceee3a2088588ab0d5f944ce00d34cb7029b888cf2db4 GIT_CLIFF_CHECKSUM_LINUX_ARM64 ?= 69e208f6e4baef3a0f9b759d5f44223aa9336b86c691072a3d0c98d35a816e69 diff --git a/build/tools.mk b/build/tools.mk index 7045250b216..a09c6c205c1 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 7138d11019e..13778d29801 100644 --- a/build/tools.yaml +++ b/build/tools.yaml @@ -248,6 +248,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.2 diff --git a/docs/contributing/contributing-releases/README.md b/docs/contributing/contributing-releases/README.md index 64612d64567..57d8045183b 100644 --- a/docs/contributing/contributing-releases/README.md +++ b/docs/contributing/contributing-releases/README.md @@ -82,6 +82,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 96fe6c33f461f70ee50b5d9e16a3e0df723ecd44 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/4] 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 b06bb853755..dd67eaea4a7 100644 --- a/build/tools.generated.mk +++ b/build/tools.generated.mk @@ -54,11 +54,11 @@ GORELEASER_CHECKSUM_LINUX_ARM64 ?= a71681b29194f08f057a68cfcaa5c6b15d907a83a2622 GORELEASER_CHECKSUM_DARWIN_AMD64 ?= 5e97d6517f73a0b6f71675a2911b15d3136c03c8907650c2b18e114b1c0f5205 GORELEASER_CHECKSUM_DARWIN_ARM64 ?= a811ff154fe136a0cfb55d00126c151fc39ec370a663d805a9ca5547445aa70c -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.2 GIT_CLIFF_CHECKSUM_LINUX_AMD64 ?= 24f397c733add5390fdceee3a2088588ab0d5f944ce00d34cb7029b888cf2db4 diff --git a/build/tools.yaml b/build/tools.yaml index 13778d29801..4baefa85dc7 100644 --- a/build/tools.yaml +++ b/build/tools.yaml @@ -250,7 +250,7 @@ tools: - name: syft makePrefix: SYFT - version: v1.51.0 + version: v1.51.1 source: type: github-release repository: anchore/syft @@ -259,16 +259,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 ef4aa642aa4..0d13b2c3838 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 00000000000..407f454c2f3 --- /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. From 62cd99cc1555e5112dae71a7e2fd9faec11361ac Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:18:35 -0700 Subject: [PATCH 3/4] feat: enhance SPDX document validation with additional checks for name and package properties Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .github/scripts/release-assets.mjs | 10 ++ .github/scripts/release-assets_test.mjs | 149 +++++++++++------- .github/scripts/release-oci-artifacts.sh | 6 + .github/scripts/release-oci-artifacts_test.sh | 70 ++++++-- .github/scripts/verify-goreleaser-snapshot.sh | 6 + .../verify-goreleaser-snapshot_test.sh | 46 +++++- 6 files changed, 221 insertions(+), 66 deletions(-) diff --git a/.github/scripts/release-assets.mjs b/.github/scripts/release-assets.mjs index 03ffe83221e..e478f0c141d 100644 --- a/.github/scripts/release-assets.mjs +++ b/.github/scripts/release-assets.mjs @@ -80,6 +80,7 @@ function verifySpdxDocument(data, name) { !/^SPDX-2\.\d+$/.test(document.spdxVersion) || document.SPDXID !== "SPDXRef-DOCUMENT" || document.dataLicense !== "CC0-1.0" || + typeof document.name !== "string" || typeof document.documentNamespace !== "string" || !document.documentNamespace.startsWith("https://") || typeof document.creationInfo?.created !== "string" || @@ -87,6 +88,15 @@ function verifySpdxDocument(data, name) { !creators.some((creator) => /^Tool: syft-/.test(creator)) || !Array.isArray(document.packages) || document.packages.length === 0 || + !document.packages.every( + (entry) => + entry !== null && + typeof entry === "object" && + !Array.isArray(entry) && + typeof entry.SPDXID === "string" && + typeof entry.name === "string" && + typeof entry.downloadLocation === "string" + ) || !Array.isArray(document.relationships) ) { throw new Error(`Release SBOM ${name} is not a valid SPDX document`); diff --git a/.github/scripts/release-assets_test.mjs b/.github/scripts/release-assets_test.mjs index 4055aa0b06d..280d6decb7c 100644 --- a/.github/scripts/release-assets_test.mjs +++ b/.github/scripts/release-assets_test.mjs @@ -77,17 +77,27 @@ function fixture({ draft = true, assets = [], starterFailures = 0 } = {}) { return { calls, core, github, inputs, outputs }; } +function spdxPackage(overrides = {}) { + return { + SPDXID: "SPDXRef-Package-radius", + name: "radius", + downloadLocation: "NOASSERTION", + ...overrides + }; +} + function spdxDocument(overrides = {}) { return JSON.stringify({ spdxVersion: "SPDX-2.3", SPDXID: "SPDXRef-DOCUMENT", dataLicense: "CC0-1.0", + name: "rad_linux_amd64", 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" }], + packages: [spdxPackage()], relationships: [], ...overrides }); @@ -395,60 +405,91 @@ 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 }); - } -}); +for (const downloadLocation of [ + "NOASSERTION", + "NONE", + "https://example.test/rad" +]) { + test(`verifies the release SBOM set with downloadLocation ${downloadLocation}`, 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: [spdxPackage({ downloadLocation })] + }) + } + ] + }); + 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 }); - } -}); +const malformedSpdxCases = [ + ["empty packages", { packages: [] }], + ["missing document name", { name: undefined }], + ["mistyped document name", { name: 42 }], + ["empty package object", { packages: [{}] }], + ["null package", { packages: [null] }], + ["string package", { packages: ["radius"] }], + ["array package", { packages: [[]] }], + ["invalid second package", { packages: [spdxPackage(), {}] }] +]; +for (const field of ["SPDXID", "name", "downloadLocation"]) { + malformedSpdxCases.push( + [ + `missing package ${field}`, + { packages: [spdxPackage({ [field]: undefined })] } + ], + [`mistyped package ${field}`, { packages: [spdxPackage({ [field]: 42 })] }] + ); +} + +for (const [description, overrides] of malformedSpdxCases) { + test(`rejects a release SBOM with ${description}`, 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(overrides) + } + ] + }); + 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/); + assert.equal(state.outputs.verified_sboms, undefined); + } 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-")); diff --git a/.github/scripts/release-oci-artifacts.sh b/.github/scripts/release-oci-artifacts.sh index 8c5139d6556..5a31cf7c100 100644 --- a/.github/scripts/release-oci-artifacts.sh +++ b/.github/scripts/release-oci-artifacts.sh @@ -697,6 +697,7 @@ verify_image_sboms() { | type == "string" and test("^SPDX-2\\.[0-9]+$")) and $document.SPDXID == "SPDXRef-DOCUMENT" and $document.dataLicense == "CC0-1.0" + and ($document.name | type == "string") and ($document.documentNamespace | type == "string" and startswith("https://")) and ($document.creationInfo.created @@ -704,6 +705,11 @@ verify_image_sboms() { and any($document.creationInfo.creators[]?; startswith("Tool: syft-")) and ($document.packages | type == "array" and length > 0) + and all($document.packages[]; + type == "object" + and (.SPDXID | type == "string") + and (.name | type == "string") + and (.downloadLocation | type == "string")) and ($document.relationships | type == "array") ) ' <<< "${sboms}" > /dev/null; then diff --git a/.github/scripts/release-oci-artifacts_test.sh b/.github/scripts/release-oci-artifacts_test.sh index cc4f36339be..2e861e49a4d 100644 --- a/.github/scripts/release-oci-artifacts_test.sh +++ b/.github/scripts/release-oci-artifacts_test.sh @@ -239,7 +239,8 @@ if [[ "$1 $2 $3" == "buildx imagetools inspect" ]]; then printf 'null\n' exit 0 fi - packages='[{"SPDXID":"SPDXRef-Package-radius","name":"radius"}]' + packages='[{"SPDXID":"SPDXRef-Package-radius","name":"radius",' + packages+='"downloadLocation":"NOASSERTION"}]' if [[ "${FAKE_SBOM_MODE:-}" == "malformed" ]]; then packages='[]' fi @@ -247,15 +248,17 @@ if [[ "$1 $2 $3" == "buildx imagetools inspect" ]]; then if [[ "${FAKE_SBOM_MODE:-}" == "partial" ]]; then platforms=(linux/amd64 linux/arm64) fi - printf '{' - for index in "${!platforms[@]}"; do - [[ "${index}" -eq 0 ]] || printf ',' - cat < "${TEST_ROOT}/error" 2>&1; then + fail_test "invalid SPDX fields were accepted: ${filter}" + return + fi + if ! grep -Fq 'missing or invalid SPDX SBOMs' "${TEST_ROOT}/error"; then + fail_test "unexpected SPDX validation error: ${filter}" + return + fi + done ((++PASS)) } @@ -718,6 +765,7 @@ main() { test_detects_version_tag_divergence test_rejects_lock_from_another_source test_verifies_image_sboms + test_rejects_invalid_spdx_fields test_rejects_missing_or_malformed_image_sboms test_retries_transient_registry_failures test_rejects_stale_cli_tag_without_overwriting diff --git a/.github/scripts/verify-goreleaser-snapshot.sh b/.github/scripts/verify-goreleaser-snapshot.sh index 040203ec0ab..f8d40e9bbeb 100644 --- a/.github/scripts/verify-goreleaser-snapshot.sh +++ b/.github/scripts/verify-goreleaser-snapshot.sh @@ -138,11 +138,17 @@ verify_spdx_json() { | type == "string" and test("^SPDX-2\\.[0-9]+$")) and .SPDXID == "SPDXRef-DOCUMENT" and .dataLicense == "CC0-1.0" + and (.name | type == "string") 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 all(.packages[]; + type == "object" + and (.SPDXID | type == "string") + and (.name | type == "string") + and (.downloadLocation | type == "string")) and (.relationships | type == "array") ' "${file}" >/dev/null || fail "invalid SPDX JSON SBOM: ${file}" } diff --git a/.github/scripts/verify-goreleaser-snapshot_test.sh b/.github/scripts/verify-goreleaser-snapshot_test.sh index c2ef5e672c8..38d9c402f59 100644 --- a/.github/scripts/verify-goreleaser-snapshot_test.sh +++ b/.github/scripts/verify-goreleaser-snapshot_test.sh @@ -80,7 +80,11 @@ write_sbom() { created: "2026-09-10T00:00:00Z", creators: ["Tool: syft-1.0.0"] }, - packages: [{SPDXID: "SPDXRef-Package-rad", name: "rad"}], + packages: [{ + SPDXID: "SPDXRef-Package-rad", + name: "rad", + downloadLocation: "NOASSERTION" + }], relationships: [] } ' >"${path}" @@ -249,6 +253,21 @@ expect_build_target_failure() { expect_failure "${description}" --skip-images } +expect_spdx_failure() { + local mutation="$1" + local sbom="${DIST}/rad_linux_amd64.sbom.json" + + write_sbom rad_linux_amd64 "${sbom}" + jq "${mutation}" "${sbom}" > "${TEST_ROOT}/invalid-sbom.json" + mv "${TEST_ROOT}/invalid-sbom.json" "${sbom}" + if run_verifier 2> "${TEST_ROOT}/spdx-error"; then + fail "verifier accepted invalid SPDX fields: ${mutation}" + fi + if ! grep -Fq 'invalid SPDX JSON SBOM' "${TEST_ROOT}/spdx-error"; then + fail "unexpected SPDX validation error: ${mutation}" + fi +} + # The verifier locates the Dockerfiles through its own repository root, so the # Dockerfile checks run against a staged copy of the repository that the test # can mutate freely. @@ -343,6 +362,31 @@ write_fixture expect_failure "a CLI SBOM that is not an SPDX document" INVALID_SBOM="" +write_fixture +for location in NOASSERTION NONE https://example.test/rad; do + jq --arg location "${location}" \ + '.packages[0].downloadLocation = $location' \ + "${DIST}/rad_linux_amd64.sbom.json" > "${TEST_ROOT}/valid-sbom.json" + mv "${TEST_ROOT}/valid-sbom.json" "${DIST}/rad_linux_amd64.sbom.json" + run_verifier +done +for mutation in \ + 'del(.name)' \ + '.name = 42' \ + '.packages = [{}]' \ + '.packages = [null]' \ + '.packages = ["rad"]' \ + '.packages = [[]]' \ + '.packages += [{}]' \ + 'del(.packages[0].SPDXID)' \ + '.packages[0].SPDXID = 42' \ + 'del(.packages[0].name)' \ + '.packages[0].name = 42' \ + 'del(.packages[0].downloadLocation)' \ + '.packages[0].downloadLocation = 42'; do + expect_spdx_failure "${mutation}" +done + # 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 From 7396781a1658e8e88ec6fe581280d6f43e26a138 Mon Sep 17 00:00:00 2001 From: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:26:03 -0700 Subject: [PATCH 4/4] feat: enhance SPDX document verification by adding package validation Signed-off-by: Dariusz Porowski <3431813+DariuszPorowski@users.noreply.github.com> --- .github/scripts/release-assets.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/scripts/release-assets.mjs b/.github/scripts/release-assets.mjs index e478f0c141d..a96e3aa601d 100644 --- a/.github/scripts/release-assets.mjs +++ b/.github/scripts/release-assets.mjs @@ -74,6 +74,7 @@ function verifySpdxDocument(data, name) { throw new Error(`Release SBOM ${name} is not valid JSON`, { cause: error }); } const creators = document?.creationInfo?.creators; + const packages = document?.packages; if ( typeof document !== "object" || document === null || @@ -86,9 +87,9 @@ function verifySpdxDocument(data, name) { typeof document.creationInfo?.created !== "string" || !Array.isArray(creators) || !creators.some((creator) => /^Tool: syft-/.test(creator)) || - !Array.isArray(document.packages) || - document.packages.length === 0 || - !document.packages.every( + !Array.isArray(packages) || + packages.length === 0 || + !packages.every( (entry) => entry !== null && typeof entry === "object" &&