Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
409 changes: 409 additions & 0 deletions .github/actions/cosign-attest-multiplatform/action.yml

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions .github/actions/cosign-sign-sbom/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,16 @@ runs:
steps:
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Pinned, not left to the installer's default. The verify side pins the
# same version and passes --new-bundle-format explicitly, so a Renovate
# bump of the installer SHA to a release defaulting to a different
# cosign major would leave signing and verification disagreeing about
# the on-registry layout, and the chart job would fail at its final
# verify with the artifact already pushed. Keep this equal to
# cosign-attest-multiplatform's cosign-version default; a release-policy
# test asserts it.
cosign-release: "v3.0.6"

- name: Install Syft
id: syft
Expand Down Expand Up @@ -95,7 +105,7 @@ runs:
SUBJECT_DIGEST: ${{ inputs.subject-digest }}
run: |
set -euo pipefail
cosign sign --yes "${SUBJECT_NAME}@${SUBJECT_DIGEST}"
timeout --foreground 120s cosign sign --yes --new-bundle-format=true "${SUBJECT_NAME}@${SUBJECT_DIGEST}"

- name: Attach CycloneDX SBOM attestation
shell: bash
Expand All @@ -105,4 +115,4 @@ runs:
SBOM_FILE: ${{ steps.sbom.outputs.sbom-file }}
run: |
set -euo pipefail
cosign attest --yes --predicate "${SBOM_FILE}" --type cyclonedx "${SUBJECT_NAME}@${SUBJECT_DIGEST}"
timeout --foreground 120s cosign attest --yes --new-bundle-format=true --predicate "${SBOM_FILE}" --type cyclonedx "${SUBJECT_NAME}@${SUBJECT_DIGEST}"
120 changes: 107 additions & 13 deletions .github/actions/cosign-verify-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,55 @@
# See the License for the specific language governing permissions and
# limitations under the License.

# WHY THE SUBJECTS ARE ASYMMETRIC
#
# For a multi-platform image this action verifies two different subjects in one
# run, which reads like a mistake until you know what each piece of evidence
# claims:
#
# Signature index digest "this artifact is ours"
# SLSA provenance index digest "this artifact came out of that build"
# CycloneDX SBOM platform digest "this root filesystem contains these packages"
# OpenVEX platform digest "those packages are/are not exploitable here"
#
# A signature and a provenance statement are about the artifact as a whole, and
# the index is what a user pulls, so they belong on the index. An SBOM and a VEX
# document each describe exactly one root filesystem, and linux/amd64 and
# linux/arm64 do not share one, so an SBOM on the index would describe neither
# child truthfully. cosign-attest-multiplatform therefore publishes SBOM and VEX
# against the platform manifests, and verification has to follow them there.
#
# The Helm chart takes the other path. It is a single OCI artifact with no
# platform children, so amd64-digest and arm64-digest are left empty and every
# check runs against the one subject, which is also what this action did before
# the split. Nothing to split means nothing to follow.

name: Cosign verify release subject
description: Verify release signature, CycloneDX SBOM attestation, and SLSA v1 provenance.
description: Verify release signature, CycloneDX SBOM and OpenVEX attestations, and SLSA v1 provenance.

inputs:
subject-name:
description: OCI repository without tag or digest.
required: true
subject-digest:
description: OCI digest in sha256:<hex> form.
description: >-
OCI digest in sha256:<hex> form. For a multi-platform image this is the
index digest, which carries the signature and the SLSA provenance.
required: true
amd64-digest:
description: >-
linux/amd64 platform manifest digest carrying the SBOM and VEX
attestations. Leave empty for a single-platform subject such as the Helm
chart, which keeps all evidence on subject-digest.
required: false
default: ""
arm64-digest:
description: >-
linux/arm64 platform manifest digest carrying the SBOM and VEX
attestations. Leave empty for a single-platform subject such as the Helm
chart, which keeps all evidence on subject-digest.
required: false
default: ""
certificate-identity-regexp:
description: Expected Fulcio certificate identity regexp.
required: true
Expand All @@ -37,12 +76,21 @@ runs:
steps:
- name: Install cosign
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
# Kept equal to the cosign-version default in
# .github/actions/cosign-attest-multiplatform. The signing side pins the
# binary so --new-bundle-format means what it says; verification reads
# the layout signing produced, so it has to be pinned to the same
# release rather than to whatever the installer currently defaults to.
cosign-release: "v3.0.6"

- name: Verify release subject
shell: bash
env:
SUBJECT_NAME: ${{ inputs.subject-name }}
SUBJECT_DIGEST: ${{ inputs.subject-digest }}
AMD64_DIGEST: ${{ inputs.amd64-digest }}
ARM64_DIGEST: ${{ inputs.arm64-digest }}
CERTIFICATE_IDENTITY_REGEXP: ${{ inputs.certificate-identity-regexp }}
CERTIFICATE_OIDC_ISSUER: ${{ inputs.certificate-oidc-issuer }}
run: |
Expand All @@ -57,18 +105,64 @@ runs:
exit 1
fi

# Half a pair is the dangerous case, not an obvious one. One empty
# digest would silently drop that architecture's SBOM and VEX checks
# while the job still reports success.
if [ -n "${AMD64_DIGEST}" ] && [ -z "${ARM64_DIGEST}" ]; then
echo "::error::amd64-digest was given without arm64-digest; pass both or neither"
exit 1
fi
if [ -z "${AMD64_DIGEST}" ] && [ -n "${ARM64_DIGEST}" ]; then
echo "::error::arm64-digest was given without amd64-digest; pass both or neither"
exit 1
fi

platform_digests=()
if [ -n "${AMD64_DIGEST}" ]; then
for digest in "${AMD64_DIGEST}" "${ARM64_DIGEST}"; do
if ! [[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then
echo "::error::platform digest must be in sha256:<64 hex> form, got '${digest}'"
exit 1
fi
platform_digests+=("${digest}")
done
fi

# --new-bundle-format is explicit on every verify-attestation because
# the signing side passes it explicitly too. Its default has moved
# between cosign releases, and a default flip on either side would look
# like a missing attestation rather than a format mismatch.
# Every cosign call is bounded, matching the attest action. These reach
# both the registry and Sigstore's TUF root, so a hung call would hold
# the release job open until the workflow's own timeout rather than
# failing it. Verification runs last, after the artifacts are already
# pushed, which is the worst point at which to hang.
verify_attestation() {
predicate_type="$1"
target="$2"
timeout --foreground 120s cosign verify-attestation \
--certificate-identity-regexp "${CERTIFICATE_IDENTITY_REGEXP}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
--new-bundle-format=true \
--type "${predicate_type}" \
"${target}"
}

subject="${SUBJECT_NAME}@${SUBJECT_DIGEST}"
cosign verify \
--certificate-identity-regexp "${CERTIFICATE_IDENTITY_REGEXP}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
"${subject}"
cosign verify-attestation \
--certificate-identity-regexp "${CERTIFICATE_IDENTITY_REGEXP}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
--type cyclonedx \
"${subject}"
cosign verify-attestation \

timeout --foreground 120s cosign verify \
--certificate-identity-regexp "${CERTIFICATE_IDENTITY_REGEXP}" \
--certificate-oidc-issuer "${CERTIFICATE_OIDC_ISSUER}" \
--type https://slsa.dev/provenance/v1 \
--new-bundle-format=true \
"${subject}"

if [ "${#platform_digests[@]}" -eq 0 ]; then
verify_attestation cyclonedx "${subject}"
else
for digest in "${platform_digests[@]}"; do
verify_attestation cyclonedx "${SUBJECT_NAME}@${digest}"
verify_attestation openvex "${SUBJECT_NAME}@${digest}"
done
fi

verify_attestation https://slsa.dev/provenance/v1 "${subject}"
63 changes: 63 additions & 0 deletions .github/actions/setup-crane/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
#
# 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.

name: Setup crane
description: Install the crane CLI as a checksum-pinned binary.

runs:
using: composite
steps:
- name: Install crane
shell: bash
env:
# NVIDIA's enterprise restricts which GitHub Actions can be used, so
# crane is installed as a binary rather than through a third-party setup
# action. The SHA256 pins protect against tarball replacement on the
# upstream release, matching how setup-oras installs oras and how
# lint-ci installs actionlint and lychee.
#
# crane ships inside the google/go-containerregistry release tarball,
# so the version and the asset names are that project's, not crane's.
CRANE_VERSION: "0.22.1"
CRANE_SHA256_AMD64: 0ab7a1d6932a213aed964ce97666c3077fe691c8606413674a8b3e0b9ec4cda0
CRANE_SHA256_ARM64: 898c0cff975f898a33e8c4580bdafb0e7c02c7faa33374e946762f97c4ab7110
run: |
set -euo pipefail

# go-containerregistry names its amd64 asset Linux_x86_64, not
# Linux_amd64 the way oras does, so the uname value passes through
# unmapped on that arch.
case "$(uname -m)" in
x86_64) arch=x86_64; sha="${CRANE_SHA256_AMD64}" ;;
aarch64 | arm64) arch=arm64; sha="${CRANE_SHA256_ARM64}" ;;
*) echo "::error::unsupported architecture $(uname -m)"; exit 1 ;;
esac

tarball="${RUNNER_TEMP}/crane.tar.gz"
bindir="${RUNNER_TEMP}/crane-bin"

curl -sSfL --retry 3 --retry-delay 5 --retry-all-errors "https://github.com/google/go-containerregistry/releases/download/v${CRANE_VERSION}/go-containerregistry_Linux_${arch}.tar.gz" -o "${tarball}"
echo "${sha} ${tarball}" | sha256sum -c -

# Onto PATH rather than into /usr/local/bin so the install needs no root.
# GITHUB_PATH applies to later steps, so this step calls it by full path.
# The tarball also carries gcrane, krane, a LICENSE and a README; naming
# the member keeps everything but crane out of the bindir.
mkdir -p "${bindir}"
tar -xzf "${tarball}" -C "${bindir}" crane
chmod +x "${bindir}/crane"
echo "${bindir}" >> "${GITHUB_PATH}"
"${bindir}/crane" version
7 changes: 5 additions & 2 deletions .github/workflows/agent-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -285,11 +285,12 @@ jobs:
tag: ${{ needs.compute-metadata.outputs.git-sha }}

- name: Sign GHCR agent image and attach SBOM
id: attest
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/agent/')
uses: ./.github/actions/cosign-sign-sbom
uses: ./.github/actions/cosign-attest-multiplatform
with:
subject-name: ${{ steps.manifest.outputs.subject-name }}
subject-digest: ${{ steps.digest.outputs.digest }}
index-digest: ${{ steps.digest.outputs.digest }}

- name: Attest GHCR agent provenance
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/agent/')
Expand All @@ -305,6 +306,8 @@ jobs:
with:
subject-name: ${{ steps.manifest.outputs.subject-name }}
subject-digest: ${{ steps.digest.outputs.digest }}
amd64-digest: ${{ steps.attest.outputs.amd64-digest }}
arm64-digest: ${{ steps.attest.outputs.arm64-digest }}
certificate-identity-regexp: ^https://github.com/${{ github.repository }}/\.github/workflows/agent-ci\.yaml@refs/tags/agent/.*$

operator-agent-tests:
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/operator-ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -501,11 +501,12 @@ jobs:
tag: ${{ needs.compute-metadata.outputs.git-sha }}

- name: Sign GHCR operator image and attach SBOM
id: attest
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/operator/')
uses: ./.github/actions/cosign-sign-sbom
uses: ./.github/actions/cosign-attest-multiplatform
with:
subject-name: ${{ steps.manifest.outputs.subject-name }}
subject-digest: ${{ steps.digest.outputs.digest }}
index-digest: ${{ steps.digest.outputs.digest }}

- name: Attest GHCR operator provenance
if: env.PUSH_TO_REGISTRY == 'true' && startsWith(github.ref, 'refs/tags/operator/')
Expand All @@ -521,6 +522,8 @@ jobs:
with:
subject-name: ${{ steps.manifest.outputs.subject-name }}
subject-digest: ${{ steps.digest.outputs.digest }}
amd64-digest: ${{ steps.attest.outputs.amd64-digest }}
arm64-digest: ${{ steps.attest.outputs.arm64-digest }}
certificate-identity-regexp: ^https://github.com/${{ github.repository }}/\.github/workflows/operator-ci\.yaml@refs/tags/operator/.*$

# Single required check for branch protection. operator-ci, agent-ci,
Expand Down
Loading
Loading