diff --git a/.claude/skills/nodewright-managing-openvex/SKILL.md b/.claude/skills/nodewright-managing-openvex/SKILL.md index e0dea3e14..affcbf96d 100644 --- a/.claude/skills/nodewright-managing-openvex/SKILL.md +++ b/.claude/skills/nodewright-managing-openvex/SKILL.md @@ -239,7 +239,10 @@ Shape: "products": [ { "@id": "pkg:oci/operator", - "identifiers": { "purl": "pkg:oci/operator" } + "identifiers": { "purl": "pkg:oci/operator" }, + "subcomponents": [ + { "@id": "pkg:golang/google.golang.org/grpc@v1.82.1" } + ] } ], "status": "not_affected", @@ -248,6 +251,33 @@ Shape: } ``` +### Subcomponents are required, and so is the version + +The validator rejects a product with no `subcomponents`, and rejects a subcomponent purl carrying no version. + +A statement naming only the product asserts that **the whole image** is unaffected. That is never what the assessment was: it was about one package, which the impact statement already names in prose. The subcomponent puts that in the part a machine reads, and downstream VEX consumers need the full package name and the version matching the scan output. + +The version is the part worth insisting on, because it makes the suppression expire by itself. Measured against a real finding, varying only the subcomponent purl: + +| subcomponent purl | suppresses | +| --- | --- | +| `pkg:golang/@` | yes | +| `pkg:golang/` (no version) | yes | +| `pkg:golang/@` | no | +| `pkg:golang/@` | no | + +So a versioned statement stops applying the moment the package is upgraded: the next scan re-reports the finding against the new version and someone reassesses it. A version-less one keeps suppressing whatever ships, including a version nobody looked at. + +Take the purl verbatim from the scan rather than composing it by hand: + +```bash +GRYPE_DB_VALIDATE_AGE=false GRYPE_CHECK_FOR_APP_UPDATE=false \ + grype ghcr.io/nvidia/nodewright/agent:latest -o json \ + | jq -r '.matches[] | select(.vulnerability.id == "") | .artifact.purl' +``` + +purl puts the version **before** the qualifiers, as in `pkg:deb/debian/libssl3t64@3.5.6-1~deb13u1?arch=arm64`. An `@` inside a qualifier value is not a version, and the validator does not accept one as a substitute. + ## Step 7: keep document-level fields as identifiers, not prose `@id`, `author`, `role`, `timestamp`, `version`, and `tooling` are metadata diff --git a/.github/actions/cosign-attest-multiplatform/action.yml b/.github/actions/cosign-attest-multiplatform/action.yml new file mode 100644 index 000000000..af9b37939 --- /dev/null +++ b/.github/actions/cosign-attest-multiplatform/action.yml @@ -0,0 +1,409 @@ +# 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. + +# Per-platform SBOM and VEX attestation for a multi-platform image. +# +# WHY THIS EXISTS, AND WHY THE SUBJECTS DIFFER +# +# NodeWright publishes a multi-platform index (linux/amd64, linux/arm64). The +# single-subject action next door, cosign-sign-sbom, attaches one CycloneDX SBOM +# to the index digest. An SBOM describes exactly one root filesystem, so an SBOM +# on an index honestly describes neither child, and a consumer who resolves +# linux/amd64 and enumerates referrers on that manifest finds nothing at all. +# This action splits the evidence so each piece hangs on the subject it is true +# about. cosign-sign-sbom stays in use for the Helm chart, which is a single OCI +# artifact with no platforms and therefore has nothing to split. +# +# Evidence Subject Why +# ------------------ ------------------ ----------------------------------- +# cosign signature Index NodeWright signs today and must keep +# doing so. AICR does not sign, and +# attestation-only images hit a known +# issue with Kyverno's +# ImageValidatingPolicy. +# CycloneDX SBOM Each platform One SBOM describes one root +# manifest filesystem. +# OpenVEX Each platform A VEX claim is verifiable only when +# manifest its product identifier binds to the +# manifest it covers. +# SLSA provenance Index Produced by the CALLING workflow's +# (not done here) actions/attest-build-provenance step. +# Provenance is about the build, not +# about one root filesystem, so it +# stays on the index. Do not move it +# here. +# +# WHY TWO FORMATS RATHER THAN TWO CYCLONEDX DOCUMENTS +# +# CycloneDX can express VEX, so folding the VEX claims into the SBOM looks like +# a simplification. It is not, because of how the evidence is discovered. +# `cosign attest --type cyclonedx` stamps predicate type +# https://cyclonedx.org/bom on whatever predicate it is handed, and an OCI +# referrer descriptor carries only digest, mediaType, size, artifactType and +# annotations. There is no name field. Two CycloneDX documents on one manifest +# are therefore indistinguishable in a referrers listing without pulling and +# decoding every payload to guess which is which. `--type openvex` stamps +# https://openvex.dev/ns instead, so the listing stays self-describing and a +# scanner can fetch only the document it wants. Do not unify them. +# +# WHY VERIFICATION DOCS USE COSIGN AND NOT A REFERRERS API CALL +# +# ghcr.io does not implement the OCI 1.1 /v2//referrers/ endpoint; +# it answers 404 MANIFEST_UNKNOWN. Every attestation published here therefore +# lands through the spec's referrers tag fallback, an ordinary index pushed to +# the tag `sha256-`. That is transparent to cosign and to oras clients, +# which fall back automatically, but it is why the documented verification path +# is `cosign verify-attestation` rather than a raw referrers query: the raw +# query returns nothing against this registry even when the evidence is present. + +name: Cosign attest multi-platform +description: Sign a multi-platform index and attest a per-platform CycloneDX SBOM and OpenVEX document to each platform manifest. + +inputs: + subject-name: + description: OCI repository without tag or digest. + required: true + index-digest: + description: Multi-platform index digest in sha256: form. + required: true + syft-version: + description: Syft version used to generate CycloneDX SBOMs. + required: false + default: "v1.38.0" + cosign-version: + description: >- + Cosign release to install. Pinned rather than inherited: --new-bundle-format + must exist in the installed binary, and the installer's default moves + between installer releases, which would make the on-registry bundle format + an implicit dependency of whatever cosign-installer happened to ship. + required: false + default: "v3.0.6" + +outputs: + amd64-digest: + description: linux/amd64 platform manifest digest that evidence was attested to. + value: ${{ steps.platforms.outputs.amd64-digest }} + arm64-digest: + description: linux/arm64 platform manifest digest that evidence was attested to. + value: ${{ steps.platforms.outputs.arm64-digest }} + +runs: + using: composite + steps: + - name: Validate inputs + shell: bash + env: + SUBJECT_NAME: ${{ inputs.subject-name }} + INDEX_DIGEST: ${{ inputs.index-digest }} + run: | + set -euo pipefail + + if [ -z "${SUBJECT_NAME}" ]; then + echo "::error::subject-name is required" + exit 1 + fi + if ! [[ "${INDEX_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "::error::index-digest must be in sha256:<64 hex> form, got '${INDEX_DIGEST}'" + exit 1 + fi + + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: ${{ inputs.cosign-version }} + + - name: Install crane + uses: ./.github/actions/setup-crane + + - name: Install Syft + id: syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + syft-version: ${{ inputs.syft-version }} + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: tools/go.mod + # The tools module is pure stdlib and has no go.sum, so there is nothing + # for setup-go's cache to key on and enabling it fails the step. + cache: false + + - name: Resolve per-platform digests + id: platforms + shell: bash + env: + SUBJECT_NAME: ${{ inputs.subject-name }} + INDEX_DIGEST: ${{ inputs.index-digest }} + run: | + set -euo pipefail + + subject="${SUBJECT_NAME}@${INDEX_DIGEST}" + declare -A resolved=() + + # This action covers exactly the platforms it loops over below. Nothing + # forces the index to contain only those, so a later PLATFORMS change + # would publish an architecture with no SBOM and no VEX, and every check + # in this file would still pass: the loop would resolve and attest the + # two it knows and never look at the third. + # + # Enumerate what the index actually ships and refuse anything + # unrecognised. `unknown/unknown` is skipped because that is how buildx + # records attestation manifests, which are evidence about the image + # rather than a platform of it. + covered="linux/amd64 linux/arm64" + shipped="$(timeout --foreground 120s crane manifest "${subject}" \ + | jq -r '.manifests[]? | "\(.platform.os // "unknown")/\(.platform.architecture // "unknown")"' \ + | sort -u)" + for platform in ${shipped}; do + case "${platform}" in + unknown/unknown) continue ;; + esac + case " ${covered} " in + *" ${platform} "*) ;; + *) + echo "::error::${subject} ships ${platform}, which this action does not attest. Add it to the loop below and to the covered list, or it releases with no SBOM and no VEX." + exit 1 + ;; + esac + done + + for arch in amd64 arm64; do + platform="linux/${arch}" + + # An absent platform fails on its own: crane prints + # "no child with platform linux/ in index ..." and exits 1. + if ! digest="$(timeout --foreground 120s crane digest --platform "${platform}" "${subject}")"; then + echo "::error::crane could not resolve ${platform} in ${subject}" + exit 1 + fi + + if ! [[ "${digest}" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "::error::crane returned '${digest}' for ${platform}, which is not sha256:<64 hex>" + exit 1 + fi + + # crane digest answers with a reference's OWN digest when that + # reference is a plain image manifest rather than an index, so a + # single-platform push succeeds here and hands back the index digest + # unchanged. Equality is the only signal that this happened, and + # without the check the whole action would quietly reattach every + # SBOM to the index, which is the exact bug it exists to fix. + if [ "${digest}" = "${INDEX_DIGEST}" ]; then + echo "::error::${platform} resolved to the index digest itself; ${subject} is not a multi-platform index" + exit 1 + fi + + resolved["${arch}"]="${digest}" + echo "${arch}-digest=${digest}" >> "${GITHUB_OUTPUT}" + echo "${platform} -> ${digest}" + done + + # Both platforms resolving to one manifest means the index lost a + # platform or the same child was selected twice. Every check above + # passes in that case, and the result is two identical SBOMs published + # as if they described different architectures: a referrers listing + # reads as complete while one architecture ships with no evidence of + # its own. + if [ "${resolved[amd64]}" = "${resolved[arm64]}" ]; then + echo "::error::linux/amd64 and linux/arm64 both resolved to ${resolved[amd64]}; a platform was lost or the index was resolved twice" + exit 1 + fi + + - name: Generate and validate per-platform evidence + id: evidence + shell: bash + env: + SUBJECT_NAME: ${{ inputs.subject-name }} + INDEX_DIGEST: ${{ inputs.index-digest }} + AMD64_DIGEST: ${{ steps.platforms.outputs.amd64-digest }} + ARM64_DIGEST: ${{ steps.platforms.outputs.arm64-digest }} + SYFT: ${{ steps.syft.outputs.cmd }} + GOFLAGS: -mod=readonly + run: | + set -euo pipefail + + if [ -z "${SYFT}" ]; then + echo "::error::Syft command was not provided by the installer" + exit 1 + fi + + # Keyed by index digest so a workflow that attests several images in one + # job does not overwrite one image's evidence with another's. + evidence_dir="${RUNNER_TEMP}/nodewright-evidence/${INDEX_DIGEST#sha256:}" + mkdir -p "${evidence_dir}" + echo "evidence-dir=${evidence_dir}" >> "${GITHUB_OUTPUT}" + + vex_source="${GITHUB_WORKSPACE}/.openvex.json" + + # The committed source is checked before anything derives from it: a + # document that violates the OpenVEX v0.2.0 contract cannot produce a + # projection worth signing, and one failure here is cheaper to read + # than the same failure repeated once per platform below. Source mode + # switches off the one rule that does not apply to it, since a + # committed document carries bare `pkg:oci/` products by design. + go -C "${GITHUB_WORKSPACE}/tools" run ./cmd/openvex validate -mode source -in "${vex_source}" + # Counted for THIS image, not across the whole document. `.openvex.json` + # is shared by every released image, so a document holding one + # operator-only statement has a legitimately empty agent projection. + # Counting all statements would abort the agent release the moment the + # first operator statement was written. + # + # The name matching mirrors parseOCIPURLName in tools/internal/openvex: + # strip the pkg:oci/ prefix, then truncate at the first `@`, `?` or `#`, + # so a product carrying a digest or a repository_url qualifier still + # matches. Keep the two in step. + subject_basename="$(basename "${SUBJECT_NAME}")" + source_statements="$(jq --arg name "${subject_basename}" ' + def ociname($id): + if ($id | type) == "string" and ($id | startswith("pkg:oci/")) + then ($id | ltrimstr("pkg:oci/") | split("@")[0] | split("?")[0] | split("#")[0]) + else null end; + [ .statements[]? + | select([ (.products // [])[]? + | (ociname(.["@id"]), ociname(.identifiers.purl)) + | select(. != null) ] | index($name)) + ] | length' "${vex_source}")" + + for arch in amd64 arm64; do + case "${arch}" in + amd64) digest="${AMD64_DIGEST}" ;; + arm64) digest="${ARM64_DIGEST}" ;; + *) echo "::error::unhandled architecture ${arch}"; exit 1 ;; + esac + + sbom_file="${evidence_dir}/${arch}.cyclonedx.json" + # `registry:` is not decoration. Without an explicit scheme syft walks + # its provider chain and tries the local Docker daemon first, which on + # an amd64 runner cannot honestly hand back a linux/arm64 root + # filesystem. Every claim this action makes rests on the SBOM + # describing the manifest it is attested to, so the source is pinned + # rather than inferred. + # + # Bounded like every other network call here, but with a longer + # deadline: this pulls a full image per platform, not a manifest. It + # runs after the images are pushed and before anything is signed, so + # a stall here would otherwise hold a half-published release open + # until the job's own limit. + timeout --foreground 600s \ + "${SYFT}" "registry:${SUBJECT_NAME}@${digest}" -o "cyclonedx-json=${sbom_file}" + + # Asserted before anything is signed. `cosign attest --type cyclonedx` + # stamps https://cyclonedx.org/bom on whatever predicate it is handed, + # so a syft invocation that silently emitted some other format would + # still publish a referrer that reads as a CycloneDX SBOM. A wrong + # format that announces itself as right is worse than a missing one: + # a consumer has no reason to look any further. + bom_format="$(jq -r '.bomFormat // empty' "${sbom_file}")" + if [ "${bom_format}" != "CycloneDX" ]; then + echo "::error::${sbom_file} has bomFormat '${bom_format}', expected CycloneDX" + exit 1 + fi + + vex_file="${evidence_dir}/${arch}.openvex.json" + go -C "${GITHUB_WORKSPACE}/tools" run ./cmd/openvex bind \ + -in "${vex_source}" \ + -out "${vex_file}" \ + -image "${SUBJECT_NAME}" \ + -digest "${digest}" + + # Validated separately from the source above, and not as a formality. + # Binding rewrites `products` on every statement it keeps, so the + # source check covered different bytes than the ones cosign is about + # to sign. Projection mode adds the rule that matters here: every + # product identifier must end in this platform's digest, which is + # what makes the claim tie to a manifest a scanner can match. + go -C "${GITHUB_WORKSPACE}/tools" run ./cmd/openvex validate \ + -mode projection \ + -in "${vex_file}" \ + -digest "${digest}" + + # Every statement naming this image must survive binding. An empty + # projection is legitimate when no statement names this image, which + # is the normal case for a document covering several images. + # + # Both sides of this comparison apply the same matching rule through + # different implementations: the count above in jq, `kept` via the Go + # binder. So what it actually catches is those two drifting apart, and + # it is worth keeping for that alone, since a drift would silently + # change which statements reach signed evidence. + # + # It does NOT catch a product name that matches no released image at + # all, for example a typo like pkg:oci/nodewright-operator: both + # counts are then zero and agree. That check needs the set of images + # the release actually publishes, which no single image's release job + # knows, so it lives in tools/tests/releasepolicy where the set is + # derivable from the workflows and a bad statement fails in the pull + # request that introduces it rather than months later at release time. + kept="$(jq '.statements | length' "${vex_file}")" + if [ "${kept}" -ne "${source_statements}" ]; then + echo "::error::.openvex.json has ${source_statements} statement(s) naming ${subject_basename} but ${kept} survived binding for linux/${arch}; a statement that names this image must reach its projection, or the release publishes a signed VEX claiming exceptions it does not carry" + exit 1 + fi + done + + # Signature on the index, not on the platforms. See the subject policy at + # the top of this file. + - name: Sign index + shell: bash + env: + SUBJECT_NAME: ${{ inputs.subject-name }} + INDEX_DIGEST: ${{ inputs.index-digest }} + run: | + set -euo pipefail + timeout --foreground 120s cosign sign \ + --yes \ + --new-bundle-format=true \ + "${SUBJECT_NAME}@${INDEX_DIGEST}" + + - name: Attest per-platform SBOM and VEX + shell: bash + env: + SUBJECT_NAME: ${{ inputs.subject-name }} + AMD64_DIGEST: ${{ steps.platforms.outputs.amd64-digest }} + ARM64_DIGEST: ${{ steps.platforms.outputs.arm64-digest }} + EVIDENCE_DIR: ${{ steps.evidence.outputs.evidence-dir }} + run: | + set -euo pipefail + + for arch in amd64 arm64; do + case "${arch}" in + amd64) digest="${AMD64_DIGEST}" ;; + arm64) digest="${ARM64_DIGEST}" ;; + *) echo "::error::unhandled architecture ${arch}"; exit 1 ;; + esac + + subject="${SUBJECT_NAME}@${digest}" + + # --new-bundle-format decides whether the attestation publishes + # through the OCI referrers API or as a legacy `.att` tag, and the + # flag's default has moved between cosign releases. Passing it + # explicitly keeps the on-registry layout a decision this repo made + # rather than one it inherited. + timeout --foreground 120s cosign attest \ + --yes \ + --new-bundle-format=true \ + --type cyclonedx \ + --predicate "${EVIDENCE_DIR}/${arch}.cyclonedx.json" \ + "${subject}" + + timeout --foreground 120s cosign attest \ + --yes \ + --new-bundle-format=true \ + --type openvex \ + --predicate "${EVIDENCE_DIR}/${arch}.openvex.json" \ + "${subject}" + done diff --git a/.github/actions/cosign-sign-sbom/action.yml b/.github/actions/cosign-sign-sbom/action.yml index f021e4da2..7a490a71d 100644 --- a/.github/actions/cosign-sign-sbom/action.yml +++ b/.github/actions/cosign-sign-sbom/action.yml @@ -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 @@ -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 @@ -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}" diff --git a/.github/actions/cosign-verify-release/action.yml b/.github/actions/cosign-verify-release/action.yml index e23cfa20b..345c886a2 100644 --- a/.github/actions/cosign-verify-release/action.yml +++ b/.github/actions/cosign-verify-release/action.yml @@ -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: form. + description: >- + OCI digest in sha256: 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 @@ -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: | @@ -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}" diff --git a/.github/actions/setup-crane/action.yml b/.github/actions/setup-crane/action.yml new file mode 100644 index 000000000..47af26d8c --- /dev/null +++ b/.github/actions/setup-crane/action.yml @@ -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 diff --git a/.github/workflows/agent-ci.yaml b/.github/workflows/agent-ci.yaml index f9e2608e9..ca7ddfda4 100644 --- a/.github/workflows/agent-ci.yaml +++ b/.github/workflows/agent-ci.yaml @@ -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/') @@ -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: diff --git a/.github/workflows/operator-ci.yaml b/.github/workflows/operator-ci.yaml index cbd3797f9..fa8009025 100644 --- a/.github/workflows/operator-ci.yaml +++ b/.github/workflows/operator-ci.yaml @@ -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/') @@ -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, diff --git a/.github/workflows/tools-ci.yaml b/.github/workflows/tools-ci.yaml new file mode 100644 index 000000000..1b1eb0874 --- /dev/null +++ b/.github/workflows/tools-ci.yaml @@ -0,0 +1,110 @@ +# 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. + +# Builds and tests the `tools` module: the OpenVEX projection tool used by the +# release evidence path, and the release-policy tests that pin it. +# +# The path filter deliberately covers `.github/**` and `.openvex.json` as well +# as `tools/**`. That looks too broad for a Go module's CI and is not: the +# release-policy tests under tools/tests/releasepolicy assert properties OF the +# workflows and composite actions (that SBOM and VEX attestations target a +# platform manifest and never the index, that every cosign attest pins the +# bundle format, that the two cosign version pins agree). Filtering on `tools/**` +# alone would mean a workflow edit that violates the policy never runs the test +# written to catch it, which is the one failure that would make these tests +# decorative. + +name: Tools CI + +on: + pull_request: + branches: + - main + paths: + - tools/** + - .github/workflows/** + - .github/actions/** + - .openvex.json + push: + branches: + - main + paths: + - tools/** + - .github/workflows/** + - .github/actions/** + - .openvex.json + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + # Opt all JS actions into Node 24 ahead of GitHub's Node 20 phase-out. + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + test: + name: Tools Unit Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: tools/go.mod + # `tools` itself has no third-party dependencies and no go.sum, which + # is what lets the release job run `go run ./cmd/openvex` with only a + # toolchain. The nested `tools/tests` module is the one thing here + # that pulls anything (a YAML parser, for the release-policy tests), + # so the cache is keyed on its go.sum rather than on the module named + # by go-version-file. Pointing setup-go at tools/go.mod for the cache + # would give it nothing to key on and fail the step. + cache-dependency-path: tools/tests/go.sum + + # `make test` runs fmt-check, vet and the unit tests. The targets live in + # tools/Makefile rather than being spelled out here, matching how + # agent-go-ci.yaml calls `make test` for its module. + # + # The release-policy tests execute the real platform-resolution script, + # which uses an associative array and so needs bash 4 or newer. The + # ubuntu-latest image provides it; the tests skip loudly rather than + # passing silently if a future runner image does not. + - name: Test + working-directory: tools + run: make test + + # Single required check for branch protection. operator-ci, agent-ci, lint-ci + # and others all publish a check named `ci-gate`; GitHub composes same-named + # required checks, so every ci-gate that posts must pass. This workflow's + # ci-gate posts only when its path filter triggers. + ci-gate: + name: ci-gate + needs: [test] + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify all required jobs passed + run: | + results='${{ toJSON(needs) }}' + echo "$results" + echo "$results" | jq -e 'to_entries | all(.value.result == "success")' diff --git a/Makefile b/Makefile index 5e912e6ce..057abb278 100644 --- a/Makefile +++ b/Makefile @@ -44,9 +44,10 @@ build: ## Build operator and agent. ##@ Test .PHONY: test -test: ## Run tests for operator and agent. +test: ## Run tests for operator, agent and tools. $(MAKE) -C operator test $(MAKE) -C agent test + $(MAKE) -C tools test .PHONY: renovate-config-check renovate-config-check: ## Validate the Renovate configuration with the pinned runner image. @@ -65,9 +66,10 @@ license_files = git ls-files -- '*.go' '*.py' '*.sh' '*.yaml' '*.yml' 'Dockerfil | grep -vE '^(operator|agent|chart)/' .PHONY: fmt -fmt: ## Run formatters for operator and agent. +fmt: ## Run formatters for operator, agent and tools. $(MAKE) -C operator fmt $(MAKE) -C agent fmt + $(MAKE) -C tools fmt .PHONY: license-fmt license-fmt: ## Run license header formatting for all code. diff --git a/SECURITY.md b/SECURITY.md index 706ba0a1c..29732760e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -52,7 +52,7 @@ Kubernetes version support is a separate policy: we CI-test and support the **la ## Verifying Release Artifacts -Every released container image and the Helm chart is signed with [Sigstore cosign](https://docs.sigstore.dev/) in keyless mode, has a CycloneDX SBOM attached as an attestation, and carries [SLSA build provenance](https://slsa.dev/). Each signing job verifies its own output before finishing. +Every released container image and the Helm chart is signed with [Sigstore cosign](https://docs.sigstore.dev/) in keyless mode, carries [SLSA build provenance](https://slsa.dev/), and has a CycloneDX SBOM attached as an attestation. The container images additionally carry an [OpenVEX](https://openvex.dev/) document. Each signing job verifies its own output before finishing. **Each artifact is signed by the workflow that builds it, gated on its own tag family, so the certificate identity differs per artifact.** A single identity pattern will not verify everything: @@ -64,6 +64,27 @@ Every released container image and the Helm chart is signed with [Sigstore cosig Verify by digest, not by tag. A tag can be repointed between the moment you verify it and the moment you pull it; a digest cannot. This is also what the signing workflows themselves do. +### Which digest carries which evidence + +The container images are multi-platform. The tag resolves to an **index**, and that index has one **platform manifest** per architecture (`linux/amd64`, `linux/arm64`). Each piece of evidence is attached to the subject it is actually true about, so for an image the evidence is split across two kinds of digest, and no single digest carries all four checks: + +| Evidence | Subject | cosign invocation | +|---|---|---| +| Signature | Index digest | `cosign verify` | +| SLSA build provenance | Index digest | `cosign verify-attestation --type https://slsa.dev/provenance/v1` | +| CycloneDX SBOM | Each platform manifest digest | `cosign verify-attestation --type cyclonedx` | +| OpenVEX | Each platform manifest digest | `cosign verify-attestation --type openvex` | + +A signature and a provenance statement describe the artifact as a whole, and the index is what you pull, so they belong on the index. An SBOM and a VEX document each describe exactly one root filesystem, and the amd64 and arm64 images do not share one, so an SBOM attached to the index would describe neither child truthfully. + +The practical consequence: **verifying the signature against a platform digest fails, and looking for the SBOM on the index digest finds nothing.** Both are expected. Resolve both kinds of digest and point each check at the right one. + +The OpenVEX document may legitimately contain zero statements. An empty `statements` array asserts that the maintainers claim no exceptions to what a scanner reports; it does not assert that the image is free of vulnerabilities. A non-empty document lists the vulnerabilities the maintainers have assessed, each with its status and justification, bound to the platform manifest it covers. + +### Verification commands + +These commands assume **cosign v3**, which is what the release workflows install. On v3 `--new-bundle-format` already defaults to `true`, so you do not need to pass it. On an older cosign, add `--new-bundle-format=true` to every `verify` and `verify-attestation` call below, or upgrade. + ```bash REPO=ghcr.io/nvidia/nodewright/operator ISSUER=https://token.actions.githubusercontent.com @@ -71,34 +92,72 @@ ISSUER=https://token.actions.githubusercontent.com # Swap both per the table above to verify the chart or the agent instead. IDENTITY='^https://github\.com/NVIDIA/nodewright/\.github/workflows/operator-ci\.yaml@refs/tags/operator/.*$' -# Resolve the tag to an immutable digest once, then use it everywhere below -DIGEST=$(crane digest "$REPO:") -IMAGE="$REPO@$DIGEST" +# Resolve the tag to an immutable index digest once +INDEX=$(crane digest "$REPO:") -# Signature (keyless, GitHub Actions OIDC identity) +# Signature (keyless, GitHub Actions OIDC identity): index digest cosign verify --certificate-oidc-issuer="$ISSUER" \ - --certificate-identity-regexp="$IDENTITY" "$IMAGE" + --certificate-identity-regexp="$IDENTITY" "$REPO@$INDEX" -# SBOM attestation (CycloneDX) -cosign verify-attestation --type cyclonedx \ - --certificate-oidc-issuer="$ISSUER" \ - --certificate-identity-regexp="$IDENTITY" "$IMAGE" - -# SLSA build provenance +# SLSA build provenance: index digest cosign verify-attestation --type https://slsa.dev/provenance/v1 \ --certificate-oidc-issuer="$ISSUER" \ - --certificate-identity-regexp="$IDENTITY" "$IMAGE" + --certificate-identity-regexp="$IDENTITY" "$REPO@$INDEX" + +# SBOM and VEX: one platform manifest digest per architecture +for PLATFORM in linux/amd64 linux/arm64; do + PLATFORM_DIGEST=$(crane digest --platform "$PLATFORM" "$REPO:") + + cosign verify-attestation --type cyclonedx \ + --certificate-oidc-issuer="$ISSUER" \ + --certificate-identity-regexp="$IDENTITY" "$REPO@$PLATFORM_DIGEST" + + cosign verify-attestation --type openvex \ + --certificate-oidc-issuer="$ISSUER" \ + --certificate-identity-regexp="$IDENTITY" "$REPO@$PLATFORM_DIGEST" +done ``` -These are the same three checks the signing workflow runs against its own output before it finishes. Pin the digest you verified in your Helm values or image reference, so the artifact you checked is the artifact that runs. +These are the same checks the signing workflow runs against its own output before it finishes. Pin the **index** digest you verified in your Helm values or image reference, so the artifact you checked is the artifact that runs. The platform digests are for verification only: pinning one would tie your deployment to a single architecture. + +### Resolving digests without crane + +`docker buildx imagetools inspect` resolves both kinds of digest. The index digest: -Without `crane`, use: +```bash +INDEX=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$REPO:") +``` + +The platform manifest digests are listed under `Manifests:` in the plain `docker buildx imagetools inspect "$REPO:"` output, one entry per `Platform:`; take the `Name:` digest of the platform you want. For scripting, read them out of the raw index instead: ```bash -DIGEST=$(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "$REPO:") +docker buildx imagetools inspect --raw "$REPO:" \ + | jq -r '.manifests[] | select(.platform.os == "linux") | "\(.platform.architecture) \(.digest)"' ``` -Take the top-level manifest digest, which is what the signature and attestations are bound to. Do not substitute one of the per-platform digests listed under `Manifests:` in the plain `docker buildx imagetools inspect` output; those are children of the index and will not verify. +### Helm chart + +The chart is verified against a **single** digest, because it is one OCI artifact with no platform children: there is nothing to split, so its signature, CycloneDX SBOM and SLSA provenance all hang on that one digest, exactly as they did before the images were split. Do not "fix" the chart instructions to chase platform digests; there are none to chase. + +```bash +CHART=ghcr.io/nvidia/nodewright/charts/nodewright +ISSUER=https://token.actions.githubusercontent.com +IDENTITY='^https://github\.com/NVIDIA/nodewright/\.github/workflows/release\.yml@refs/tags/chart/.*$' + +DIGEST=$(crane digest "$CHART:") +SUBJECT="$CHART@$DIGEST" + +cosign verify --certificate-oidc-issuer="$ISSUER" \ + --certificate-identity-regexp="$IDENTITY" "$SUBJECT" + +cosign verify-attestation --type cyclonedx \ + --certificate-oidc-issuer="$ISSUER" \ + --certificate-identity-regexp="$IDENTITY" "$SUBJECT" + +cosign verify-attestation --type https://slsa.dev/provenance/v1 \ + --certificate-oidc-issuer="$ISSUER" \ + --certificate-identity-regexp="$IDENTITY" "$SUBJECT" +``` The identity regexp is deliberately narrow: it pins the signer to one workflow and one tag family in this repository, not merely to the NVIDIA organization. Loosening it to `refs/tags/` would let a signature produced by any other release path satisfy the check. Swap the workflow and tag family per the table above when verifying the chart or the agent. diff --git a/docs/contributing/release-process.md b/docs/contributing/release-process.md index f309e0485..790c88d68 100644 --- a/docs/contributing/release-process.md +++ b/docs/contributing/release-process.md @@ -360,21 +360,72 @@ Note: ### Verify release signatures and attestations -Release workflows publish keyless Sigstore signatures, CycloneDX SBOM attestations, and SLSA v1 provenance attestations for GHCR image and Helm chart release artifacts. +Release workflows publish keyless Sigstore signatures, CycloneDX SBOM attestations, and SLSA v1 provenance attestations for GHCR image and Helm chart release artifacts. Container images additionally carry an OpenVEX attestation. Prerequisites: -- Docker buildx (`docker buildx version`) -- cosign (`cosign version`) +- cosign v3 (`cosign version`); the release workflows install v3.0.6 +- crane (`crane version`), or Docker buildx (`docker buildx version`) - jq (`jq --version`) +These commands assume cosign v3, where `--new-bundle-format` already defaults to `true`. On an older cosign, add `--new-bundle-format=true` to every `verify` and `verify-attestation` call below, or upgrade; the signing side passes it explicitly, so a default mismatch surfaces as a missing attestation rather than as a format error. + +#### Which digest carries which evidence + +The operator and agent images are multi-platform: the tag resolves to an index, and the index has one platform manifest per architecture (`linux/amd64`, `linux/arm64`). Evidence is attached to the subject it is true about, so the image evidence is split across two kinds of digest: + +| Evidence | Subject | Predicate type | +|---|---|---| +| Signature | Index digest | n/a (`cosign verify`) | +| SLSA build provenance | Index digest | `https://slsa.dev/provenance/v1` | +| CycloneDX SBOM | Each platform manifest digest | `cyclonedx` | +| OpenVEX | Each platform manifest digest | `openvex` | + +A signature and a provenance statement describe the artifact as a whole, and the index is what a user pulls, so they stay on the index. An SBOM and a VEX document each describe exactly one root filesystem, and the amd64 and arm64 images do not share one, so an SBOM on the index would describe neither child truthfully. This is the same subject policy the signing action documents in `.github/actions/cosign-attest-multiplatform/action.yml`. + +The consequence for anyone verifying a release: **the signature does not verify against a platform digest, and the SBOM is not found on the index digest.** Both are expected. Resolve both kinds of digest and point each check at the right one. + +The OpenVEX document may legitimately contain zero statements. An empty `statements` array asserts that the maintainers claim no exceptions to what a scanner reports; it does not assert the image is clean. See the notes in `tools/internal/openvex` for why an empty document is allowed here. + +#### Resolving digests + +With crane: + +```bash +IMAGE=ghcr.io/nvidia/nodewright/operator +TAG=v0.19.0 + +INDEX=$(crane digest "${IMAGE}:${TAG}") +AMD64=$(crane digest --platform linux/amd64 "${IMAGE}:${TAG}") +ARM64=$(crane digest --platform linux/arm64 "${IMAGE}:${TAG}") +``` + +Without crane, Docker buildx resolves the same values. The index digest: + +```bash +INDEX=$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest}}' | jq -r '.digest') +``` + +The platform manifest digests are listed under `Manifests:` in the plain `docker buildx imagetools inspect "${IMAGE}:${TAG}"` output, one entry per `Platform:`; take the `Name:` digest of the platform you want. For scripting, read them out of the raw index: + +```bash +AMD64=$(docker buildx imagetools inspect --raw "${IMAGE}:${TAG}" \ + | jq -r '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "amd64") | .digest') +ARM64=$(docker buildx imagetools inspect --raw "${IMAGE}:${TAG}" \ + | jq -r '.manifests[] | select(.platform.os == "linux" and .platform.architecture == "arm64") | .digest') +``` + +Verify by immutable digest, never by tag: a tag can be repointed between the moment you verify it and the moment you pull it. + +#### Certificate identity + The expected OIDC issuer is: ```bash https://token.actions.githubusercontent.com ``` -The expected certificate identity must match the specific component release workflow identity on that component's tag refs. +The expected certificate identity must match the specific component release workflow identity on that component's tag refs. Each artifact is signed by the workflow that builds it, gated on its own tag family, so one pattern will not verify everything. For operator images: @@ -394,66 +445,90 @@ For Helm chart artifacts: ^https://github.com/NVIDIA/nodewright/\.github/workflows/release\.yml@refs/tags/chart/.*$ ``` -Resolve the artifact digest first, then verify by immutable digest: - #### Operator image ```bash IMAGE=ghcr.io/nvidia/nodewright/operator -TAG=v0.15.0 -DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest}}' | jq -r '.digest') -SUBJECT="${IMAGE}@${DIGEST}" +TAG=v0.19.0 IDENTITY='^https://github.com/NVIDIA/nodewright/\.github/workflows/operator-ci\.yaml@refs/tags/operator/.*$' ISSUER='https://token.actions.githubusercontent.com' +INDEX=$(crane digest "${IMAGE}:${TAG}") + +# Signature and provenance: index digest cosign verify \ --certificate-identity-regexp "${IDENTITY}" \ --certificate-oidc-issuer "${ISSUER}" \ - "${SUBJECT}" -cosign verify-attestation \ - --certificate-identity-regexp "${IDENTITY}" \ - --certificate-oidc-issuer "${ISSUER}" \ - --type cyclonedx \ - "${SUBJECT}" + "${IMAGE}@${INDEX}" cosign verify-attestation \ --certificate-identity-regexp "${IDENTITY}" \ --certificate-oidc-issuer "${ISSUER}" \ --type https://slsa.dev/provenance/v1 \ - "${SUBJECT}" + "${IMAGE}@${INDEX}" + +# SBOM and VEX: one platform manifest digest per architecture +for PLATFORM in linux/amd64 linux/arm64; do + PLATFORM_DIGEST=$(crane digest --platform "${PLATFORM}" "${IMAGE}:${TAG}") + + cosign verify-attestation \ + --certificate-identity-regexp "${IDENTITY}" \ + --certificate-oidc-issuer "${ISSUER}" \ + --type cyclonedx \ + "${IMAGE}@${PLATFORM_DIGEST}" + cosign verify-attestation \ + --certificate-identity-regexp "${IDENTITY}" \ + --certificate-oidc-issuer "${ISSUER}" \ + --type openvex \ + "${IMAGE}@${PLATFORM_DIGEST}" +done ``` #### Agent image +Identical to the operator, with the agent repository, tag family and identity: + ```bash IMAGE=ghcr.io/nvidia/nodewright/agent -TAG=v6.4.0 -DIGEST=$(docker buildx imagetools inspect "${IMAGE}:${TAG}" --format '{{json .Manifest}}' | jq -r '.digest') -SUBJECT="${IMAGE}@${DIGEST}" +TAG=v6.4.2 IDENTITY='^https://github.com/NVIDIA/nodewright/\.github/workflows/agent-ci\.yaml@refs/tags/agent/.*$' ISSUER='https://token.actions.githubusercontent.com' +INDEX=$(crane digest "${IMAGE}:${TAG}") + cosign verify \ --certificate-identity-regexp "${IDENTITY}" \ --certificate-oidc-issuer "${ISSUER}" \ - "${SUBJECT}" -cosign verify-attestation \ - --certificate-identity-regexp "${IDENTITY}" \ - --certificate-oidc-issuer "${ISSUER}" \ - --type cyclonedx \ - "${SUBJECT}" + "${IMAGE}@${INDEX}" cosign verify-attestation \ --certificate-identity-regexp "${IDENTITY}" \ --certificate-oidc-issuer "${ISSUER}" \ --type https://slsa.dev/provenance/v1 \ - "${SUBJECT}" + "${IMAGE}@${INDEX}" + +for PLATFORM in linux/amd64 linux/arm64; do + PLATFORM_DIGEST=$(crane digest --platform "${PLATFORM}" "${IMAGE}:${TAG}") + + cosign verify-attestation \ + --certificate-identity-regexp "${IDENTITY}" \ + --certificate-oidc-issuer "${ISSUER}" \ + --type cyclonedx \ + "${IMAGE}@${PLATFORM_DIGEST}" + cosign verify-attestation \ + --certificate-identity-regexp "${IDENTITY}" \ + --certificate-oidc-issuer "${ISSUER}" \ + --type openvex \ + "${IMAGE}@${PLATFORM_DIGEST}" +done ``` #### Helm chart +The chart keeps single-subject verification, because it is one OCI artifact with no platform children: there is nothing to split, so signature, SBOM and provenance all hang on the same digest. Do not "fix" this section to chase platform digests; the chart has none. + ```bash CHART=ghcr.io/nvidia/nodewright/charts/nodewright TAG=v0.19.0 -DIGEST=$(docker buildx imagetools inspect "${CHART}:${TAG}" --format '{{json .Manifest}}' | jq -r '.digest') +DIGEST=$(crane digest "${CHART}:${TAG}") SUBJECT="${CHART}@${DIGEST}" IDENTITY='^https://github.com/NVIDIA/nodewright/\.github/workflows/release\.yml@refs/tags/chart/.*$' ISSUER='https://token.actions.githubusercontent.com' @@ -474,13 +549,15 @@ cosign verify-attestation \ "${SUBJECT}" ``` -Use the same command pattern for each released artifact: +#### Subjects at a glance + +| Artifact | Signature and provenance subject | SBOM and VEX subject | +|----------|----------------------------------|----------------------| +| GHCR operator image | `ghcr.io/nvidia/nodewright/operator@` | `ghcr.io/nvidia/nodewright/operator@`, per platform | +| GHCR agent image | `ghcr.io/nvidia/nodewright/agent@` | `ghcr.io/nvidia/nodewright/agent@`, per platform | +| GHCR Helm chart | `ghcr.io/nvidia/nodewright/charts/nodewright@` | same digest; no platforms, and no VEX | -| Artifact | Immutable OCI subject | -|----------|-----------------------| -| GHCR operator image | `ghcr.io/nvidia/nodewright/operator@sha256:` | -| GHCR agent image | `ghcr.io/nvidia/nodewright/agent@sha256:` | -| GHCR Helm chart | `ghcr.io/nvidia/nodewright/charts/nodewright@sha256:` | +These are the same checks `.github/actions/cosign-verify-release` runs against each release before the workflow finishes. ## Vulnerability Scanning diff --git a/tools/Makefile b/tools/Makefile new file mode 100644 index 000000000..64c331486 --- /dev/null +++ b/tools/Makefile @@ -0,0 +1,80 @@ +# 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. + +SHELL = /usr/bin/env bash -o pipefail +.SHELLFLAGS = -ec + +GO ?= go + +# Every Go module under tools/, relative to this Makefile. +MODULES ?= . tests + +## This module has no third-party dependencies and no go.sum, which is what lets +## the release job run `go run ./cmd/openvex bind` and `go run ./cmd/openvex +## validate` with a toolchain and no module download. Keep it that way: adding +## one turns every release into a fetch. +## +## tests/ is a module of its own so the release-policy tests can read workflow +## YAML with a real parser without that dependency reaching this module. A +## nested module is invisible to `./...`, so every Go target below has to name +## it; `make test` from here still runs everything, which is what CI calls. +## +## There are deliberately no license targets here. The ROOT Makefile's +## license_files glob excludes only operator|agent|chart, so tools/ is already +## covered repo-wide. A target here would double-cover it. + +.PHONY: all +all: test + +##@ General +.PHONY: help +help: ## Display this help. + @awk 'BEGIN {FS = ":.*##"; printf "\n\033[1;31mUsage:\033[0m\n make \033[3;1;36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1;31m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) + +##@ Development + +.PHONY: fmt +fmt: ## Format go files in every module. + @for module in $(MODULES); do \ + (cd $$module && $(GO) fmt ./...); \ + done + +.PHONY: fmt-check +fmt-check: ## Fail if any go file is not gofmt-clean. + @# gofmt walks the directory tree rather than the module graph, so this one + @# invocation already covers tests/. vet and the unit tests do not, and name + @# it explicitly below. + @unformatted="$$(gofmt -l .)"; \ + if [ -n "$$unformatted" ]; then \ + echo "::error::gofmt reports unformatted files:"; \ + echo "$$unformatted"; \ + exit 1; \ + fi + +.PHONY: vet +vet: ## Run go vet against every module. + @for module in $(MODULES); do \ + (cd $$module && $(GO) vet ./...); \ + done + +.PHONY: unit-tests +unit-tests: ## Run the unit tests in every module. + @for module in $(MODULES); do \ + (cd $$module && $(GO) test ./... -count=1); \ + done + +.PHONY: test +test: fmt-check vet unit-tests ## Run formatting, vet and unit tests. diff --git a/tools/cmd/openvex/main.go b/tools/cmd/openvex/main.go new file mode 100644 index 000000000..7d9b927c2 --- /dev/null +++ b/tools/cmd/openvex/main.go @@ -0,0 +1,80 @@ +// 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. + +// Command openvex is the release evidence tool for the repository's OpenVEX +// document. It binds .openvex.json to a platform manifest digest and checks +// both the committed source and the resulting projection against the OpenVEX +// v0.2.0 contract, so a release cannot publish a VEX attestation no scanner +// can apply. +// +// The two halves are one binary because the release step runs them over the +// same documents in the same loop, and a shared package is what keeps their +// idea of "an OpenVEX document" from diverging. +// +// This file does dispatch and nothing else. Each subcommand's flags, rules and +// exit status live with its implementation in internal/openvex. +package main + +import ( + "fmt" + "os" + + "github.com/NVIDIA/nodewright/tools/internal/openvex" +) + +const usage = `openvex binds and validates the repository's OpenVEX document. + +Usage: + openvex [flags] + +Commands: + bind Project .openvex.json onto one platform manifest digest, rewriting + every kept statement's products to pkg:oci/@. + openvex bind -in .openvex.json -out amd64.openvex.json \ + -image ghcr.io/nvidia/nodewright/operator -digest sha256:<64 hex> + + validate Check one OpenVEX document against the v0.2.0 contract the release + depends on. Source mode checks the committed document; projection + mode additionally requires every product bound to the given digest. + openvex validate -mode source -in .openvex.json + openvex validate -mode projection -in amd64.openvex.json -digest sha256:<64 hex> + +Run "openvex -h" for a command's flags. +` + +func main() { + if len(os.Args) < 2 { + fmt.Fprint(os.Stderr, usage) + os.Exit(2) + } + command, args := os.Args[1], os.Args[2:] + // Neither subcommand is handed os.Stdin. Both read their document from + // -in, so a stdin parameter would be a wire nothing ever pulls on, and a + // reader that is never read is the kind of thing a later change quietly + // starts depending on. + switch command { + case "bind": + os.Exit(openvex.RunBind(args, os.Stdout, os.Stderr)) + case "validate": + os.Exit(openvex.RunValidate(args, os.Stdout, os.Stderr)) + case "-h", "--help", "help": + fmt.Fprint(os.Stdout, usage) + os.Exit(0) + default: + fmt.Fprintf(os.Stderr, "openvex: unknown command %q\n\n", command) + fmt.Fprint(os.Stderr, usage) + os.Exit(2) + } +} diff --git a/tools/go.mod b/tools/go.mod new file mode 100644 index 000000000..998361caf --- /dev/null +++ b/tools/go.mod @@ -0,0 +1,3 @@ +module github.com/NVIDIA/nodewright/tools + +go 1.27.1 diff --git a/tools/internal/openvex/bind.go b/tools/internal/openvex/bind.go new file mode 100644 index 000000000..65912198f --- /dev/null +++ b/tools/internal/openvex/bind.go @@ -0,0 +1,413 @@ +// 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. + +package openvex + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" +) + +// ociPURLPrefix is the purl scheme and type every product identifier in +// .openvex.json uses. Product matching and rewriting both key off it. +const ociPURLPrefix = "pkg:oci/" + +// projectionTooling is what every projection reports for tooling, replacing +// whatever the source carried. OpenVEX v0.2.0 defines the field as an +// identifier for what generated the document, and the projection is generated +// here. Replacing rather than passing through is deliberate: a fixed constant +// makes the value structurally independent of the committed source, so prose +// that grows in .openvex.json cannot reach a signed attestation. +const projectionTooling = "openvex bind (github.com/NVIDIA/nodewright/tools/cmd/openvex); statements curated manually" + +// Options names the platform manifest a projection binds to. +type Options struct { + // Image is the image name without tag or digest, e.g. + // ghcr.io/nvidia/nodewright/operator. Only its basename participates in + // product identity; the registry and namespace do not, because purl + // pkg:oci names are repository-independent. + Image string + // Digest is the linux/ child manifest digest, never the index + // digest: an SBOM and its VEX describe one root filesystem. + Digest string +} + +// Result is a rendered projection plus the counts a caller logs. Kept is the +// number of source statements that named this image; Dropped is the number that +// named some other one. +type Result struct { + Document []byte + Kept int + Dropped int +} + +// Bind projects the committed OpenVEX document onto one platform manifest. +// +// Three things happen and nothing else does. Statements that do not name this +// image are dropped rather than re-pointed, because binding them to this digest +// would publish a signed claim about a product they were never triaged against. +// Statements that do name it keep their status, justification, impact +// statement, action statement, subcomponents and vulnerability object +// byte-for-byte, with products replaced by the single digest-qualified +// identifier pkg:oci/@ that makes the claim verifiable +// against the manifest it ships with. The document-level tooling field is set +// to projectionTooling; every other document field passes through. +// +// The output is a pure function of (source, Image, Digest): no wall clock, no +// UUID, and encoding/json orders object keys, so re-running on the same inputs +// reproduces the same bytes. The source document's own timestamp and version +// pass through, which keeps provenance pointing back at the reviewed file +// rather than at the moment the release happened to run. +func Bind(source []byte, o Options) (*Result, error) { + if !digestPattern.MatchString(o.Digest) { + return nil, fmt.Errorf("digest %q must be in the form sha256:<64 lowercase hex>", o.Digest) + } + name, err := imageBasename(o.Image) + if err != nil { + return nil, fmt.Errorf("resolving the product name: %w", err) + } + + doc, err := Decode(source) + if err != nil { + return nil, fmt.Errorf("reading the source document: %w", err) + } + sourceID, ok := NonEmptyString(doc["@id"]) + if !ok { + return nil, errors.New("source document must set @id to a non-empty string") + } + statements, ok := doc["statements"].([]any) + if !ok { + return nil, errors.New("source document must set statements to an array") + } + + purl := ociPURLPrefix + name + "@" + o.Digest + kept := make([]any, 0, len(statements)) + for i, entry := range statements { + statement, ok := entry.(map[string]any) + if !ok { + return nil, fmt.Errorf("statement %d must be a JSON object", i) + } + products, ok := statement["products"].([]any) + if !ok || len(products) == 0 { + return nil, fmt.Errorf("statement %d must list at least one product", i) + } + bound, matched, bindErr := bindProducts(products, name, purl) + if bindErr != nil { + return nil, fmt.Errorf("statement %d: %w", i, bindErr) + } + if !matched { + continue + } + // Copy before mutating so the decoded source tree is left intact and a + // later failure cannot leave a half-rewritten document behind. + projected := make(map[string]any, len(statement)) + for key, value := range statement { + projected[key] = value + } + projected["products"] = []any{bound} + kept = append(kept, projected) + } + + // A projection is a distinct document from the file it derives from, so it + // needs a distinct @id. Deriving it from the source @id plus the bound purl + // keeps that identity stable across runs and self-describing about which + // image and platform it covers. + doc["@id"] = sourceID + "#" + name + "@" + o.Digest + doc["statements"] = kept + doc["tooling"] = projectionTooling + + rendered, err := encodeDocument(doc) + if err != nil { + return nil, fmt.Errorf("rendering the projection: %w", err) + } + return &Result{Document: rendered, Kept: len(kept), Dropped: len(statements) - len(kept)}, nil +} + +// bindProducts returns the single digest-qualified product replacing a +// statement's product list. The bool reports whether any listed product named +// this image; false means the statement belongs to a different one and is +// dropped. Subcomponents survive the collapse: they are gathered from every +// matching product, in source order, deduplicated by encoded form. +func bindProducts(products []any, name, purl string) (map[string]any, bool, error) { + matched := false + subcomponents := make([]any, 0) + seen := map[string]struct{}{} + for _, entry := range products { + product, ok := entry.(map[string]any) + if !ok { + return nil, false, errors.New("every product must be a JSON object") + } + if !productNames(product, name) { + continue + } + matched = true + listed, listErr := subcomponentList(product) + if listErr != nil { + return nil, false, fmt.Errorf("malformed product: %w", listErr) + } + for _, sub := range listed { + key, err := json.Marshal(sub) + if err != nil { + return nil, false, fmt.Errorf("encoding a subcomponent: %w", err) + } + if _, duplicate := seen[string(key)]; duplicate { + continue + } + seen[string(key)] = struct{}{} + subcomponents = append(subcomponents, sub) + } + } + if !matched { + return nil, false, nil + } + bound := map[string]any{ + "@id": purl, + "identifiers": map[string]any{"purl": purl}, + } + if len(subcomponents) > 0 { + bound["subcomponents"] = subcomponents + } + return bound, true, nil +} + +// subcomponentList returns a product's subcomponents, or nil when it has none. +// A present-but-malformed value is fatal rather than ignored: subcomponents +// narrow a statement to specific packages, so silently dropping one would +// publish a signed claim broader than the curated source made, and passing one +// through unchecked would sign a component that is not a component. +// +// OpenVEX v0.2.0 types every entry as a Component object, so a scalar member is +// rejected too: it marshals without complaint and would otherwise be copied +// verbatim into the signed projection. +func subcomponentList(product map[string]any) ([]any, error) { + value, present := product["subcomponents"] + if !present || value == nil { + return nil, nil + } + list, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("subcomponents must be an array, got %T", value) + } + for i, entry := range list { + if _, ok := entry.(map[string]any); !ok { + return nil, fmt.Errorf("subcomponents[%d] must be a JSON object, got %T", i, entry) + } + } + return list, nil +} + +// productNames reports whether a product identifies the image named by +// basename. Both @id and identifiers.purl are consulted because the OpenVEX +// spec makes either sufficient to identify a component. +func productNames(product map[string]any, basename string) bool { + candidates := []any{product["@id"]} + if identifiers, ok := product["identifiers"].(map[string]any); ok { + candidates = append(candidates, identifiers["purl"]) + } + for _, candidate := range candidates { + value, ok := NonEmptyString(candidate) + if !ok { + continue + } + if parsed, ok := parseOCIPURLName(value); ok && parsed == basename { + return true + } + } + return false +} + +// parseOCIPURLName pulls the package name out of a pkg:oci/... identifier, +// discarding any version (@sha256:...) or qualifier (?repository_url=...) the +// source happens to carry. A non-OCI identifier yields false. +func parseOCIPURLName(identifier string) (string, bool) { + if !strings.HasPrefix(identifier, ociPURLPrefix) { + return "", false + } + name := strings.TrimPrefix(identifier, ociPURLPrefix) + for _, separator := range []string{"@", "?", "#"} { + if at := strings.Index(name, separator); at >= 0 { + name = name[:at] + } + } + if name == "" { + return "", false + } + return name, true +} + +// imageBasename strips the registry, namespace, and any tag or digest from an +// image reference, leaving the repository name purl pkg:oci uses. +func imageBasename(image string) (string, error) { + if image == "" || strings.TrimSpace(image) != image { + return "", errors.New("image must be a non-empty reference with no surrounding whitespace") + } + trimmed := image + if at := strings.Index(trimmed, "@"); at >= 0 { + trimmed = trimmed[:at] + } + // Deliberately not path.Base: it strips trailing slashes, so + // "ghcr.io/nvidia/" would silently yield the namespace "nvidia" as the + // repository name. The last slash-separated segment must be present. + base := trimmed + if slash := strings.LastIndex(base, "/"); slash >= 0 { + base = base[slash+1:] + } + if colon := strings.LastIndex(base, ":"); colon >= 0 { + base = base[:colon] + } + if base == "" || base == "." { + return "", fmt.Errorf("image %q has no repository name", image) + } + return base, nil +} + +// encodeDocument renders the projection. encoding/json sorts map keys, so the +// byte output is stable for a given input even though the source key order is +// not preserved. +func encodeDocument(doc map[string]any) ([]byte, error) { + var buffer bytes.Buffer + encoder := json.NewEncoder(&buffer) + encoder.SetEscapeHTML(false) + encoder.SetIndent("", " ") + if err := encoder.Encode(doc); err != nil { + return nil, fmt.Errorf("encoding the bound document: %w", err) + } + return buffer.Bytes(), nil +} + +// errMissingOut is fatal rather than defaulted: the projection is signed +// evidence, so where it lands is the caller's decision and not this tool's. +var errMissingOut = errors.New("-out is required") + +// bindOptions is the parsed `openvex bind` command line. +type bindOptions struct { + in string + out string + image string + digest string +} + +// RunBind projects the committed OpenVEX document onto one platform manifest +// digest, producing the document the release attests to that manifest +// alongside its SBOM. It returns the process exit status rather than taking +// it: 0 on success, 1 on a failure to bind, 2 on an unusable command line. +// +// Why a projection and not a committed file: verification requires the VEX +// product identifier to bind to the specific manifest the claim covers, and +// that digest does not exist until the image is built. .openvex.json stays the +// reviewed source of truth with bare pkg:oci/ products; this rewrites +// those to pkg:oci/@sha256: and writes the +// result to a file the attest step feeds to cosign. The source document is +// never modified. +// +// What it does not do: no format translation, no status or justification +// mapping, no merging of scan results. Statuses, justifications, impact +// statements and subcomponents pass through untouched, because the curated +// judgment is exactly what has value and any rewrite of it is a guess. The one +// field it rewrites besides products and @id is document-level tooling, which +// names this tool rather than whatever the source carried. +// +// Output is deterministic: a pure function of the source bytes, the image name +// and the digest, with no wall clock and no UUID, so a re-run of a release +// produces byte-identical evidence. +// +// Usage: +// +// openvex bind -in .openvex.json -out vex-linux-amd64.openvex.json \ +// -image ghcr.io/nvidia/nodewright/operator \ +// -digest sha256:<64 hex> +func RunBind(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("openvex bind", flag.ContinueOnError) + flags.SetOutput(stderr) + + var o bindOptions + flags.StringVar(&o.in, "in", ".openvex.json", "source OpenVEX document to project") + flags.StringVar(&o.out, "out", "", "path to write the digest-bound projection (required)") + flags.StringVar(&o.image, "image", "", "image name without tag or digest, e.g. ghcr.io/nvidia/nodewright/operator (required)") + flags.StringVar(&o.digest, "digest", "", "platform manifest digest to bind products to, sha256:<64 hex> (required)") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + if err := runBind(o, stdout); err != nil { + fmt.Fprintf(stderr, "openvex bind: %v\n", err) + return 1 + } + return 0 +} + +// runBind reads the source document, binds it, and writes the projection. It +// reports the kept and dropped statement counts on stdout so a release log +// records which statements this platform actually received. +func runBind(o bindOptions, stdout io.Writer) error { + if o.out == "" { + return errMissingOut + } + source, err := readSource(o.in) + if err != nil { + return err + } + result, err := Bind(source, Options{Image: o.image, Digest: o.digest}) + if err != nil { + return err + } + if err := os.WriteFile(o.out, result.Document, 0o600); err != nil { + return fmt.Errorf("writing %s: %w", o.out, err) + } + if _, err := fmt.Fprintf(stdout, "bound %d statement(s) to %s@%s (%d not for this image)\n", + result.Kept, o.image, o.digest, result.Dropped); err != nil { + return fmt.Errorf("writing the summary line: %w", err) + } + return nil +} + +// readSource reads the OpenVEX document under a size cap. os.ReadFile would +// allocate the whole file before any check, so a path that resolves to a pipe, +// /proc or a network mount could exhaust memory. +func readSource(path string) (data []byte, err error) { + if path == "" { + return nil, errMissingIn + } + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", path, err) + } + defer func() { + if closeErr := file.Close(); closeErr != nil && err == nil { + data, err = nil, fmt.Errorf("closing %s: %w", path, closeErr) + } + }() + + data, err = io.ReadAll(io.LimitReader(file, maxDocumentBytes+1)) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + if int64(len(data)) > maxDocumentBytes { + return nil, fmt.Errorf("%s exceeds the %d byte OpenVEX size limit", path, maxDocumentBytes) + } + if len(data) == 0 { + return nil, fmt.Errorf("%s is empty", path) + } + return data, nil +} diff --git a/tools/internal/openvex/bind_cli_test.go b/tools/internal/openvex/bind_cli_test.go new file mode 100644 index 000000000..d930062da --- /dev/null +++ b/tools/internal/openvex/bind_cli_test.go @@ -0,0 +1,194 @@ +// 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. + +package openvex + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeSource drops a source document into a temp dir and returns its path. +func writeSource(t *testing.T, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "openvex.json") + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write source: %v", err) + } + return path +} + +// bindArgs renders the command line `openvex bind` is invoked with. An omitted +// -in is passed as the empty string rather than left off, so the case exercises +// the missing-input rejection instead of falling back on the flag default. +func bindArgs(in, out, image, digest string, omitIn, omitOut bool) []string { + args := []string{"-image", image, "-digest", digest} + if omitIn { + in = "" + } + args = append(args, "-in", in) + if !omitOut { + args = append(args, "-out", out) + } + return args +} + +func TestRunBind(t *testing.T) { + t.Parallel() + tests := []struct { + name string + source string + omitOut bool + omitIn bool + image string + digest string + wantErr bool + wantKept string + }{ + { + name: "writes the projection", + source: sourceDocument, + image: operatorImage, + digest: amd64Digest, + wantKept: "bound 1 statement(s)", + }, + { + name: "image with no statements still writes a document", + source: sourceDocument, + image: "ghcr.io/nvidia/nodewright/charts/nodewright", + digest: amd64Digest, + wantKept: "bound 0 statement(s)", + }, + {name: "missing -out", source: sourceDocument, omitOut: true, image: operatorImage, digest: amd64Digest, wantErr: true}, + {name: "missing -in", source: sourceDocument, omitIn: true, image: operatorImage, digest: amd64Digest, wantErr: true}, + {name: "empty source file", source: "", image: operatorImage, digest: amd64Digest, wantErr: true}, + {name: "bad digest", source: sourceDocument, image: operatorImage, digest: "sha256:nope", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + out := filepath.Join(t.TempDir(), "bound.openvex.json") + args := bindArgs(writeSource(t, tt.source), out, tt.image, tt.digest, tt.omitIn, tt.omitOut) + + var stdout, stderr bytes.Buffer + code := RunBind(args, &stdout, &stderr) + if (code != 0) != tt.wantErr { + t.Fatalf("RunBind() = %d, wantErr %t\nstderr: %s", code, tt.wantErr, stderr.String()) + } + if tt.wantErr { + if code != 1 { + t.Errorf("RunBind() = %d on a rejected invocation, want 1", code) + } + if !strings.Contains(stderr.String(), "openvex bind:") { + t.Errorf("failure did not explain itself on stderr: %q", stderr.String()) + } + return + } + if stderr.Len() != 0 { + t.Errorf("RunBind() wrote to stderr on success: %q", stderr.String()) + } + if !strings.Contains(stdout.String(), tt.wantKept) { + t.Errorf("RunBind() stdout = %q, want it to contain %q", stdout.String(), tt.wantKept) + } + written, readErr := os.ReadFile(out) + if readErr != nil { + t.Fatalf("read projection: %v", readErr) + } + if len(written) == 0 { + t.Error("projection file is empty") + } + info, statErr := os.Stat(out) + if statErr != nil { + t.Fatalf("stat projection: %v", statErr) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("projection mode = %o, want 600", perm) + } + }) + } +} + +// TestRunBindIsDeterministic re-runs the whole command path, not just Bind, so +// a release that repeats produces byte-identical evidence on disk. +func TestRunBindIsDeterministic(t *testing.T) { + t.Parallel() + source := writeSource(t, sourceDocument) + outputs := make([][]byte, 2) + for i := range outputs { + out := filepath.Join(t.TempDir(), "bound.openvex.json") + var stderr bytes.Buffer + if code := RunBind(bindArgs(source, out, operatorImage, amd64Digest, false, false), &bytes.Buffer{}, &stderr); code != 0 { + t.Fatalf("RunBind() = %d on pass %d, want 0\nstderr: %s", code, i, stderr.String()) + } + written, err := os.ReadFile(out) + if err != nil { + t.Fatalf("read projection: %v", err) + } + outputs[i] = written + } + if !bytes.Equal(outputs[0], outputs[1]) { + t.Errorf("two runs differ:\n%s\n---\n%s", outputs[0], outputs[1]) + } +} + +// TestRunBindOnMissingFile keeps the failure a clean error rather than a panic; +// a release that cannot find its VEX source must stop before attesting. +func TestRunBindOnMissingFile(t *testing.T) { + t.Parallel() + args := bindArgs( + filepath.Join(t.TempDir(), "absent.json"), + filepath.Join(t.TempDir(), "bound.json"), + operatorImage, amd64Digest, false, false) + var stderr bytes.Buffer + if code := RunBind(args, &bytes.Buffer{}, &stderr); code == 0 { + t.Fatal("RunBind() = 0, want a failure on a missing source") + } +} + +// TestRunBindRejectsAnUnknownFlag pins that a bad command line returns a status +// rather than exiting the process, which is what lets every case above run +// in-process. Parse errors are reported on the writer the caller supplied, not +// on os.Stderr. +func TestRunBindRejectsAnUnknownFlag(t *testing.T) { + t.Parallel() + var stdout, stderr bytes.Buffer + if code := RunBind([]string{"-nope"}, &stdout, &stderr); code != 2 { + t.Errorf("RunBind(-nope) = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "-nope") { + t.Errorf("parse failure was not reported on the supplied stderr: %q", stderr.String()) + } +} + +// TestReadSourceEnforcesSizeCap covers the bounded-read rule: the source is +// streamed under a limit rather than allocated whole, so a path that resolves +// to something unbounded fails instead of exhausting memory. +func TestReadSourceEnforcesSizeCap(t *testing.T) { + t.Parallel() + oversized := strings.Repeat("a", int(maxDocumentBytes)+1) + if _, err := readSource(writeSource(t, oversized)); err == nil { + t.Fatal("readSource() error = nil, want a size-limit failure") + } + atLimit := strings.Repeat("a", int(maxDocumentBytes)) + if _, err := readSource(writeSource(t, atLimit)); err != nil { + t.Fatalf("readSource() error = %v at exactly the limit, want success", err) + } + if _, err := readSource(writeSource(t, "")); err == nil { + t.Fatal("readSource() error = nil on an empty file, want a rejection") + } +} diff --git a/tools/internal/openvex/bind_test.go b/tools/internal/openvex/bind_test.go new file mode 100644 index 000000000..53f68c425 --- /dev/null +++ b/tools/internal/openvex/bind_test.go @@ -0,0 +1,605 @@ +// 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. + +package openvex + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +const ( + amd64Digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + arm64Digest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + operatorImage = "ghcr.io/nvidia/nodewright/operator" + agentImage = "ghcr.io/nvidia/nodewright/agent" +) + +// sourceDocument is a structurally faithful stand-in for .openvex.json: one +// statement scoped to the operator, one scoped to the agent, and the +// document-level fields the projection must carry through unchanged. +const sourceDocument = `{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/NVIDIA/nodewright/.openvex.json", + "author": "NVIDIA NodeWright maintainers", + "role": "document creator", + "timestamp": "2026-09-16T00:00:00Z", + "version": 14, + "tooling": "manual curation", + "statements": [ + { + "vulnerability": {"name": "GO-2026-5942", "description": "net/http"}, + "products": [ + {"@id": "pkg:oci/operator", "identifiers": {"purl": "pkg:oci/operator"}} + ], + "status": "not_affected", + "justification": "vulnerable_code_cannot_be_controlled_by_adversary", + "impact_statement": "the listener is cluster-internal", + "action_statement": "none required" + }, + { + "vulnerability": {"name": "GHSA-vjc4-5qp5-m44j"}, + "products": [ + {"@id": "pkg:oci/agent", "identifiers": {"purl": "pkg:oci/agent"}} + ], + "status": "not_affected", + "justification": "component_not_present" + } + ] +}` + +// decode renders a projection back into a tree so assertions read against +// structure rather than against formatting. +func decode(t *testing.T, document []byte) map[string]any { + t.Helper() + var doc map[string]any + if err := json.Unmarshal(document, &doc); err != nil { + t.Fatalf("projection is not valid JSON: %v\n%s", err, document) + } + return doc +} + +// statementsOf extracts the statement list from a decoded projection. +func statementsOf(t *testing.T, doc map[string]any) []any { + t.Helper() + statements, ok := doc["statements"].([]any) + if !ok { + t.Fatalf("projection statements = %T, want an array", doc["statements"]) + } + return statements +} + +// productPURLs returns the @id and identifiers.purl of every product on a +// statement, so a rewrite can be asserted on both. +func productPURLs(t *testing.T, statement any) []string { + t.Helper() + object, ok := statement.(map[string]any) + if !ok { + t.Fatalf("statement = %T, want an object", statement) + } + products, ok := object["products"].([]any) + if !ok { + t.Fatalf("statement products = %T, want an array", object["products"]) + } + found := make([]string, 0, len(products)*2) + for _, entry := range products { + product, ok := entry.(map[string]any) + if !ok { + t.Fatalf("product = %T, want an object", entry) + } + id, _ := product["@id"].(string) + found = append(found, id) + identifiers, ok := product["identifiers"].(map[string]any) + if !ok { + t.Fatalf("product identifiers = %T, want an object", product["identifiers"]) + } + purl, _ := identifiers["purl"].(string) + found = append(found, purl) + } + return found +} + +func TestBindSelectsAndRewritesProducts(t *testing.T) { + t.Parallel() + tests := []struct { + name string + image string + digest string + wantKept int + wantDropped int + wantPURLs []string + }{ + { + name: "operator statement binds to the operator digest", + image: operatorImage, + digest: amd64Digest, + wantKept: 1, + wantDropped: 1, + wantPURLs: []string{"pkg:oci/operator@" + amd64Digest, "pkg:oci/operator@" + amd64Digest}, + }, + { + name: "arm64 binds the other platform manifest", + image: operatorImage, + digest: arm64Digest, + wantKept: 1, + wantDropped: 1, + wantPURLs: []string{"pkg:oci/operator@" + arm64Digest, "pkg:oci/operator@" + arm64Digest}, + }, + { + name: "agent statement is the one kept for the agent image", + image: agentImage, + digest: amd64Digest, + wantKept: 1, + wantDropped: 1, + wantPURLs: []string{"pkg:oci/agent@" + amd64Digest, "pkg:oci/agent@" + amd64Digest}, + }, + { + name: "tagged reference resolves to the same basename", + image: operatorImage + ":v0.15.0", + digest: amd64Digest, + wantKept: 1, + wantDropped: 1, + wantPURLs: []string{"pkg:oci/operator@" + amd64Digest, "pkg:oci/operator@" + amd64Digest}, + }, + { + name: "image with no statements yields an empty projection", + image: "ghcr.io/nvidia/nodewright/charts/nodewright", + digest: amd64Digest, + wantKept: 0, + wantDropped: 2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := Bind([]byte(sourceDocument), Options{Image: tt.image, Digest: tt.digest}) + if err != nil { + t.Fatalf("Bind() error = %v, want nil", err) + } + if result.Kept != tt.wantKept || result.Dropped != tt.wantDropped { + t.Fatalf("Bind() kept/dropped = %d/%d, want %d/%d", + result.Kept, result.Dropped, tt.wantKept, tt.wantDropped) + } + statements := statementsOf(t, decode(t, result.Document)) + if len(statements) != tt.wantKept { + t.Fatalf("projection has %d statement(s), want %d", len(statements), tt.wantKept) + } + if tt.wantKept == 0 { + return + } + if got := productPURLs(t, statements[0]); !reflect.DeepEqual(got, tt.wantPURLs) { + t.Errorf("product identifiers = %q, want %q", got, tt.wantPURLs) + } + }) + } +} + +// TestBindDropsForeignStatementsRatherThanRepointing is the rule that keeps the +// projection honest: a statement triaged against a different image must not be +// re-aimed at this digest, because that would publish a signed claim nobody +// made. +func TestBindDropsForeignStatementsRatherThanRepointing(t *testing.T) { + t.Parallel() + result, err := Bind([]byte(sourceDocument), Options{Image: operatorImage, Digest: amd64Digest}) + if err != nil { + t.Fatalf("Bind() error = %v, want nil", err) + } + rendered := string(result.Document) + if strings.Contains(rendered, "GHSA-vjc4-5qp5-m44j") { + t.Error("projection carries the agent-only statement; it must be dropped, not re-pointed") + } + if !strings.Contains(rendered, "GO-2026-5942") { + t.Error("projection is missing the operator statement") + } +} + +// TestBindPassesCuratedFieldsThrough covers the judgment fields: status, +// justification, impact and action statements, subcomponents and the +// vulnerability object are the curated value, so the projection must copy them +// verbatim. +func TestBindPassesCuratedFieldsThrough(t *testing.T) { + t.Parallel() + const source = `{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/NVIDIA/nodewright/.openvex.json", + "author": "NVIDIA NodeWright maintainers", + "timestamp": "2026-09-16T00:00:00Z", + "version": 7, + "statements": [ + { + "vulnerability": {"name": "CVE-2026-0001", "description": "example", "aliases": ["GHSA-aaaa-bbbb-cccc"]}, + "products": [ + { + "@id": "pkg:oci/operator", + "identifiers": {"purl": "pkg:oci/operator"}, + "subcomponents": [{"@id": "pkg:golang/golang.org/x/net@v0.44.0"}] + } + ], + "status": "affected", + "action_statement": "upgrade to v0.16.0", + "action_statement_timestamp": "2026-09-16T00:00:00Z" + } + ] +}` + result, err := Bind([]byte(source), Options{Image: operatorImage, Digest: amd64Digest}) + if err != nil { + t.Fatalf("Bind() error = %v, want nil", err) + } + doc := decode(t, result.Document) + statement, ok := statementsOf(t, doc)[0].(map[string]any) + if !ok { + t.Fatal("projected statement is not an object") + } + + sourceDoc := decode(t, []byte(source)) + sourceStatement, ok := statementsOf(t, sourceDoc)[0].(map[string]any) + if !ok { + t.Fatal("source statement is not an object") + } + for _, field := range []string{"vulnerability", "status", "action_statement", "action_statement_timestamp"} { + if !reflect.DeepEqual(statement[field], sourceStatement[field]) { + t.Errorf("%s = %#v, want %#v unchanged", field, statement[field], sourceStatement[field]) + } + } + + products, _ := statement["products"].([]any) + if len(products) != 1 { + t.Fatalf("projected products = %d, want exactly the bound one", len(products)) + } + product, _ := products[0].(map[string]any) + sourceProducts, _ := sourceStatement["products"].([]any) + sourceProduct, _ := sourceProducts[0].(map[string]any) + if !reflect.DeepEqual(product["subcomponents"], sourceProduct["subcomponents"]) { + t.Errorf("subcomponents = %#v, want %#v unchanged", product["subcomponents"], sourceProduct["subcomponents"]) + } + + for _, field := range []string{"@context", "author", "timestamp", "version"} { + if !reflect.DeepEqual(doc[field], sourceDoc[field]) { + t.Errorf("document %s = %#v, want %#v unchanged", field, doc[field], sourceDoc[field]) + } + } + if !bytes.Contains(result.Document, []byte(`"version": 7`)) { + t.Errorf("version did not round-trip as an integer:\n%s", result.Document) + } +} + +// TestBindNormalizesTooling covers the one document field that is rewritten +// rather than passed through, whatever the source carried. +func TestBindNormalizesTooling(t *testing.T) { + t.Parallel() + const narrative = "this sentence exists only to make the source tooling field long. " + tests := []struct { + name string + tooling string + }{ + {name: "short source value", tooling: `"tooling": "manual curation",`}, + {name: "narrative source value", tooling: `"tooling": "` + strings.Repeat(narrative, 96) + `",`}, + {name: "absent source value", tooling: ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + source := `{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/NVIDIA/nodewright/.openvex.json", + ` + tt.tooling + ` + "statements": [] +}` + result, err := Bind([]byte(source), Options{Image: operatorImage, Digest: amd64Digest}) + if err != nil { + t.Fatalf("Bind() error = %v, want nil", err) + } + tooling, _ := decode(t, result.Document)["tooling"].(string) + if tooling != projectionTooling { + t.Errorf("tooling = %q, want %q", tooling, projectionTooling) + } + if len(tooling) >= 256 { + t.Errorf("tooling is %d bytes, want it well under 256", len(tooling)) + } + }) + } +} + +// TestBindIsDeterministic is the reproducibility contract: the same inputs must +// produce the same signed bytes on a re-run of a release. +func TestBindIsDeterministic(t *testing.T) { + t.Parallel() + for _, image := range []string{operatorImage, agentImage} { + first, err := Bind([]byte(sourceDocument), Options{Image: image, Digest: amd64Digest}) + if err != nil { + t.Fatalf("Bind() error = %v, want nil", err) + } + for i := range 8 { + again, err := Bind([]byte(sourceDocument), Options{Image: image, Digest: amd64Digest}) + if err != nil { + t.Fatalf("Bind() error = %v on run %d, want nil", err, i) + } + if !bytes.Equal(first.Document, again.Document) { + t.Fatalf("run %d for %s differs from the first:\n%s\n---\n%s", + i, image, first.Document, again.Document) + } + } + } +} + +// TestBindDoesNotMutateSource guards the caller's buffer: the reviewed document +// is the source of truth and every platform binds from the same bytes. +func TestBindDoesNotMutateSource(t *testing.T) { + t.Parallel() + source := []byte(sourceDocument) + original := append([]byte(nil), source...) + if _, err := Bind(source, Options{Image: operatorImage, Digest: amd64Digest}); err != nil { + t.Fatalf("Bind() error = %v, want nil", err) + } + if !bytes.Equal(source, original) { + t.Error("Bind() mutated the source buffer") + } +} + +// TestBindEmptyStatements covers the repo's current real state: nothing +// measured is legitimately not affected, so the committed document has no +// statements and the projection must be a valid empty one rather than an error. +func TestBindEmptyStatements(t *testing.T) { + t.Parallel() + const source = `{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/NVIDIA/nodewright/.openvex.json", + "author": "NVIDIA NodeWright maintainers", + "timestamp": "2026-09-16T00:00:00Z", + "version": 1, + "statements": [] +}` + result, err := Bind([]byte(source), Options{Image: operatorImage, Digest: amd64Digest}) + if err != nil { + t.Fatalf("Bind() error = %v, want nil on an empty statement list", err) + } + if result.Kept != 0 || result.Dropped != 0 { + t.Errorf("kept/dropped = %d/%d, want 0/0", result.Kept, result.Dropped) + } + if statements := statementsOf(t, decode(t, result.Document)); len(statements) != 0 { + t.Errorf("statements = %#v, want an empty array", statements) + } + if !bytes.Contains(result.Document, []byte(`"statements": []`)) { + t.Errorf("statements did not render as an empty array:\n%s", result.Document) + } +} + +// TestBindMergesSubcomponentsAcrossMatchingProducts covers the collapse to a +// single bound product: a statement that lists the image more than once keeps +// every subcomponent, in source order, without duplicates. +func TestBindMergesSubcomponentsAcrossMatchingProducts(t *testing.T) { + t.Parallel() + const source = `{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/NVIDIA/nodewright/.openvex.json", + "statements": [ + { + "vulnerability": {"name": "CVE-2026-0002"}, + "products": [ + {"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/a@v1"}]}, + {"@id": "pkg:oci/operator?repository_url=ghcr.io", "subcomponents": [{"@id": "pkg:golang/a@v1"}, {"@id": "pkg:golang/b@v2"}]}, + {"@id": "pkg:oci/agent", "subcomponents": [{"@id": "pkg:golang/c@v3"}]} + ], + "status": "not_affected", + "justification": "component_not_present" + } + ] +}` + result, err := Bind([]byte(source), Options{Image: operatorImage, Digest: amd64Digest}) + if err != nil { + t.Fatalf("Bind() error = %v, want nil", err) + } + statement, ok := statementsOf(t, decode(t, result.Document))[0].(map[string]any) + if !ok { + t.Fatal("projected statement is not an object") + } + products, _ := statement["products"].([]any) + if len(products) != 1 { + t.Fatalf("projected products = %d, want exactly the bound one", len(products)) + } + product, _ := products[0].(map[string]any) + want := []any{ + map[string]any{"@id": "pkg:golang/a@v1"}, + map[string]any{"@id": "pkg:golang/b@v2"}, + } + if !reflect.DeepEqual(product["subcomponents"], want) { + t.Errorf("subcomponents = %#v, want %#v", product["subcomponents"], want) + } +} + +func TestBindRejectsBadInput(t *testing.T) { + t.Parallel() + tests := []struct { + name string + source string + image string + digest string + }{ + {name: "empty digest", source: sourceDocument, image: operatorImage}, + {name: "short digest", source: sourceDocument, image: operatorImage, digest: "sha256:abc"}, + { + name: "uppercase digest", + source: sourceDocument, + image: operatorImage, + digest: "sha256:AAAA111111111111111111111111111111111111111111111111111111111111", + }, + {name: "unprefixed digest", source: sourceDocument, image: operatorImage, digest: strings.TrimPrefix(amd64Digest, "sha256:")}, + {name: "empty image", source: sourceDocument, digest: amd64Digest}, + {name: "image with whitespace", source: sourceDocument, image: " " + operatorImage, digest: amd64Digest}, + {name: "image with no repository name", source: sourceDocument, image: "ghcr.io/nvidia/", digest: amd64Digest}, + {name: "source is not JSON", source: "not json", image: operatorImage, digest: amd64Digest}, + {name: "source is a JSON array", source: "[]", image: operatorImage, digest: amd64Digest}, + {name: "source is JSON null", source: "null", image: operatorImage, digest: amd64Digest}, + {name: "source is a JSON scalar", source: "42", image: operatorImage, digest: amd64Digest}, + {name: "source is empty", source: "", image: operatorImage, digest: amd64Digest}, + { + name: "document has no @id", + source: `{"@context": "https://openvex.dev/ns/v0.2.0", "statements": []}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "statements is not an array", + source: `{"@id": "x", "statements": {}}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "statements is missing", + source: `{"@id": "x"}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "statement is not an object", + source: `{"@id": "x", "statements": ["nope"]}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "statement lists no products", + source: `{"@id": "x", "statements": [{"products": []}]}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "statement omits products", + source: `{"@id": "x", "statements": [{"status": "not_affected"}]}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "product is not an object", + source: `{"@id": "x", "statements": [{"products": ["pkg:oci/operator"]}]}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "trailing JSON value after the document", + source: sourceDocument + `{"@id": "second"}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "trailing scalar after the document", + source: sourceDocument + ` 1`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "subcomponents is not an array", + source: `{"@id": "x", "statements": [{"products": [{"@id": "pkg:oci/operator", "subcomponents": "nope"}]}]}`, + image: operatorImage, digest: amd64Digest, + }, + { + name: "subcomponents array has a scalar member", + source: `{"@id": "x", "statements": [{"products": [{"@id": "pkg:oci/operator", "subcomponents": ["nope"]}]}]}`, + image: operatorImage, digest: amd64Digest, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, err := Bind([]byte(tt.source), Options{Image: tt.image, Digest: tt.digest}) + if err == nil { + t.Fatalf("Bind() error = nil, want a rejection; got %s", result.Document) + } + }) + } +} + +func TestImageBasename(t *testing.T) { + t.Parallel() + tests := []struct { + name string + image string + want string + wantErr bool + }{ + {name: "nested namespace", image: operatorImage, want: "operator"}, + {name: "single namespace", image: "ghcr.io/nvidia/nodewright", want: "nodewright"}, + {name: "tagged", image: agentImage + ":v6.4.2", want: "agent"}, + {name: "digest pinned", image: operatorImage + "@" + amd64Digest, want: "operator"}, + {name: "bare name", image: "operator", want: "operator"}, + {name: "empty", image: "", wantErr: true}, + {name: "trailing slash", image: "ghcr.io/nvidia/", wantErr: true}, + {name: "leading space", image: " operator", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := imageBasename(tt.image) + if (err != nil) != tt.wantErr { + t.Fatalf("imageBasename(%q) error = %v, wantErr %t", tt.image, err, tt.wantErr) + } + if !tt.wantErr && got != tt.want { + t.Errorf("imageBasename(%q) = %q, want %q", tt.image, got, tt.want) + } + }) + } +} + +func TestParseOCIPURLName(t *testing.T) { + t.Parallel() + tests := []struct { + name string + identifier string + want string + wantOK bool + }{ + {name: "bare", identifier: "pkg:oci/operator", want: "operator", wantOK: true}, + {name: "digest qualified", identifier: "pkg:oci/operator@" + amd64Digest, want: "operator", wantOK: true}, + { + name: "with repository_url qualifier", + identifier: "pkg:oci/operator?repository_url=ghcr.io/nvidia/nodewright", + want: "operator", wantOK: true, + }, + {name: "non-oci purl", identifier: "pkg:golang/golang.org/x/net", wantOK: false}, + {name: "not a purl", identifier: operatorImage, wantOK: false}, + {name: "empty name", identifier: "pkg:oci/", wantOK: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, ok := parseOCIPURLName(tt.identifier) + if ok != tt.wantOK { + t.Fatalf("parseOCIPURLName(%q) ok = %t, want %t", tt.identifier, ok, tt.wantOK) + } + if tt.wantOK && got != tt.want { + t.Errorf("parseOCIPURLName(%q) = %q, want %q", tt.identifier, got, tt.want) + } + }) + } +} + +// TestBindCommittedDocument binds the file the release actually ships, so a +// change to .openvex.json that this tool cannot project fails here rather than +// mid-release. +func TestBindCommittedDocument(t *testing.T) { + t.Parallel() + source, err := os.ReadFile(filepath.Join(repoRoot(t), openVEXPath)) + if err != nil { + t.Fatalf("read the committed document: %v", err) + } + for _, image := range []string{operatorImage, agentImage} { + result, err := Bind(source, Options{Image: image, Digest: amd64Digest}) + if err != nil { + t.Fatalf("Bind(%s) error = %v, want nil", image, err) + } + doc := decode(t, result.Document) + if _, ok := doc["statements"].([]any); !ok { + t.Errorf("projection for %s has statements = %T, want an array", image, doc["statements"]) + } + if tooling, _ := doc["tooling"].(string); tooling != projectionTooling { + t.Errorf("projection for %s has tooling = %q, want %q", image, tooling, projectionTooling) + } + } +} diff --git a/tools/internal/openvex/document.go b/tools/internal/openvex/document.go new file mode 100644 index 000000000..e2ce0173c --- /dev/null +++ b/tools/internal/openvex/document.go @@ -0,0 +1,126 @@ +// 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. + +// Package openvex implements the two halves of the release evidence path for +// the repository's OpenVEX document: binding it to one platform manifest, and +// checking it against the v0.2.0 contract. Both run over the same two +// documents in the same release step, so they live in one package and share +// one idea of what an OpenVEX document is; a divergence would mean the bytes +// that were checked are not the bytes that were bound. +// +// This file holds what both halves agree on. RunBind and RunValidate in +// bind.go and validate.go are the entry points tools/cmd/openvex dispatches +// to; neither calls os.Exit, so both are directly testable. +package openvex + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" +) + +// Context is the pinned OpenVEX namespace. Equality against it is what pins +// the spec version: it is a local, network-free way to assert the enums below +// still describe the document. Fetching a schema or installing a validator +// would add a new failure mode at the point in a release where a failure is +// most expensive. +const Context = "https://openvex.dev/ns/v0.2.0" + +// digestPattern is the only accepted form for a platform manifest digest, +// shared by both halves because they have to agree on it: the binder refuses +// to produce a document bound to a malformed digest, and the validator refuses +// to check one against it. Either way a bad digest stops the release rather +// than yielding a VEX bound to nothing. +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// maxDocumentBytes caps every document read. The committed source is a few KB +// of hand-curated statements and a projection is smaller still; a megabyte is +// far past any plausible growth and well inside what a release runner can hold. +const maxDocumentBytes int64 = 1 << 20 + +// errMissingIn is shared because both subcommands name the document they read +// with the same flag. +var errMissingIn = errors.New("-in is required") + +// Statuses is the v0.2.0 statement status enum. +var Statuses = []string{"not_affected", "affected", "fixed", "under_investigation"} + +// Justifications is the v0.2.0 justification enum, valid only on a +// not_affected statement. +var Justifications = []string{ + "component_not_present", + "vulnerable_code_not_present", + "vulnerable_code_not_in_execute_path", + "vulnerable_code_cannot_be_controlled_by_adversary", + "inline_mitigations_already_exist", +} + +// Decode's failure modes, distinguished so a caller can report which one it +// hit rather than printing one message for every kind of unusable input. +var ( + ErrNotJSON = errors.New("not valid JSON") + ErrNotObject = errors.New("not a JSON object") + ErrTrailingValue = errors.New("must hold exactly one JSON value") +) + +// Decode parses an OpenVEX document into a generic tree. +// +// UseNumber is set so integer fields such as version re-encode as the literal +// they arrived as, not as a float. +func Decode(source []byte) (map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(source)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, fmt.Errorf("%w: %w", ErrNotJSON, err) + } + doc, ok := value.(map[string]any) + if !ok || doc == nil { + return nil, ErrNotObject + } + // json.Decoder stops at the end of the first value, so a document followed + // by a second one would be silently truncated to the first. Input that + // ambiguous must not become signed evidence. + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return nil, ErrTrailingValue + } + return doc, nil +} + +// NonEmptyString reports whether value is a string with non-whitespace +// content, which is what both tools mean by a field being set. +func NonEmptyString(value any) (string, bool) { + text, ok := value.(string) + if !ok || strings.TrimSpace(text) == "" { + return "", false + } + return text, true +} + +// Field returns object[key], or nil when object is not a JSON object. It keeps +// a caller from having to type-assert at every level of a path that may not +// exist in a malformed document. +func Field(object any, key string) any { + mapping, ok := object.(map[string]any) + if !ok { + return nil + } + return mapping[key] +} diff --git a/tools/internal/openvex/validate.go b/tools/internal/openvex/validate.go new file mode 100644 index 000000000..cb8fb42f4 --- /dev/null +++ b/tools/internal/openvex/validate.go @@ -0,0 +1,469 @@ +// 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. + +package openvex + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "maps" + "os" + "slices" + "strings" +) + +// Mode names which of the two documents is being checked. Exactly one rule is +// mode-specific, and the modes exist only to switch it. +type Mode string + +const ( + // ModeSource checks the committed .openvex.json, which carries bare + // pkg:oci/ products by design, so the binding rule is switched off + // rather than left unsatisfied. + ModeSource Mode = "source" + // ModeProjection checks what RunBind derived for one platform + // manifest, where every product identifier must end in that manifest's + // digest. + ModeProjection Mode = "projection" +) + +// maxDocumentFieldBytes bounds every document-level string. The longest +// legitimate value is a projection @id: the source URL plus +// `#@sha256:<64 hex>`, 130 bytes for the longest NodeWright image name. +// 256 leaves room for roughly double that and for a tool identifier carrying a +// repository URL and a version, while staying far below the 8,010 byte +// `tooling` value that prompted the bound: in a sibling project that field grew +// into a revision changelog and shipped signed on all seven release images +// before anyone noticed (NVIDIA/aicr#2706). Every string the spec defines at +// this level (@context, @id, author, role, tooling, timestamp) is an +// identifier, not prose, so a field over the bound is prose in an identifier +// slot, which is the shape of the bug. +// +// Statement-level fields are deliberately NOT bounded: impact_statement is +// evidence and is expected to run to paragraphs. Bound the identifier fields, +// not the evidence fields. +const maxDocumentFieldBytes = 256 + +// Validate returns every way doc fails the OpenVEX v0.2.0 contract, in a +// deterministic order, or nil when it satisfies it. Every problem is reported +// rather than only the first: a release that fails this check is going to be +// fixed by hand, and one round trip per problem is a release held open. +// +// digest is the platform manifest every product identifier must be bound to. +// It is empty in source mode, which switches the binding rule off. +func Validate(doc map[string]any, digest string) []string { + problems := documentProblems(doc) + statements, _ := doc["statements"].([]any) + for index, entry := range statements { + problems = append(problems, statementProblems(index, entry, digest)...) + } + return problems +} + +func documentProblems(doc map[string]any) []string { + var problems []string + if doc["@context"] != Context { + problems = append(problems, fmt.Sprintf("@context must be %s, got %s", Context, asJSON(doc["@context"]))) + } + for _, key := range []string{"@id", "author", "timestamp"} { + if _, ok := NonEmptyString(doc[key]); !ok { + problems = append(problems, key+" must be a non-empty string") + } + } + if _, ok := doc["version"].(json.Number); !ok { + problems = append(problems, "version must be a number") + } + if _, ok := doc["statements"].([]any); !ok { + problems = append(problems, "statements must be an array") + } + // Sorted because Go map iteration order is randomized and a validator that + // reorders its own output between runs on one unchanged document reads as + // a flake rather than as a finding. + for _, key := range slices.Sorted(maps.Keys(doc)) { + text, ok := doc[key].(string) + if !ok || len(text) <= maxDocumentFieldBytes { + continue + } + problems = append(problems, fmt.Sprintf( + "document field %s is %d bytes, over the %d byte bound for a document-level identifier", + key, len(text), maxDocumentFieldBytes)) + } + return problems +} + +func statementProblems(index int, entry any, digest string) []string { + statement, ok := entry.(map[string]any) + if !ok { + return []string{fmt.Sprintf("statement %d must be a JSON object", index)} + } + + var problems []string + report := func(format string, arguments ...any) { + problems = append(problems, fmt.Sprintf("statement %d %s", index, fmt.Sprintf(format, arguments...))) + } + + if _, ok := NonEmptyString(Field(statement["vulnerability"], "name")); !ok { + report("must set vulnerability.name to a non-empty string") + } + + // The spec marks `products` optional only because it can cascade from an + // encapsulating format; this document defines no such product tree, and + // grype and trivy match statements by products[].purl, so a statement + // without products is unusable here. + products, _ := statement["products"].([]any) + if len(products) == 0 { + report("must list at least one product") + } + if anyProduct(products, func(product any) bool { return !identifiable(product) }) { + report("has a product with neither @id nor identifiers.purl") + } + if digest != "" && anyProduct(products, func(product any) bool { return !digestBound(product, digest) }) { + report("has a product identifier not bound to @%s", digest) + } + if anyProduct(products, func(product any) bool { return !componentList(product) }) { + report("has a product whose subcomponents is not an array of objects") + } + // A statement naming only a product asserts that the whole image is + // unaffected. The assessment is never actually that broad: it is about one + // package, and the impact statement says so in prose. Subcomponents put + // that in the machine-readable part, which is what a VEX consumer reads. + // + // The version is the part worth insisting on. A versioned subcomponent + // stops matching once the package is upgraded, so the suppression expires + // by itself; without one it silently carries over to a version nobody + // assessed. Measured: a subcomponent purl whose version does not match the + // scan stops suppressing, while a version-less one keeps suppressing + // whatever ships. + if anyProduct(products, func(product any) bool { return !hasSubcomponents(product) }) { + report("has a product with no subcomponents; name the package the assessment is about, not just the image") + } + for _, identifier := range unversionedSubcomponents(products) { + report("has subcomponent %q without a version; a version-less subcomponent keeps suppressing after the package is upgraded", identifier) + } + + status := statement["status"] + if !inEnum(Statuses, status) { + report("status %s is not one of %s", asJSON(status), strings.Join(Statuses, ", ")) + } + if status == "not_affected" && !anySet(statement, "justification", "impact_statement") { + report("is not_affected and must carry a justification or an impact_statement") + } + if justification := statement["justification"]; justification != nil && !inEnum(Justifications, justification) { + report("justification %s is not one of %s", asJSON(justification), strings.Join(Justifications, ", ")) + } + if status == "affected" && !anySet(statement, "action_statement") { + report("is affected and must carry an action_statement") + } + return problems +} + +// identifiable reports whether a product names something a scanner can match. +// Either @id or identifiers.purl is enough: the spec makes either sufficient. +func identifiable(product any) bool { + if _, ok := NonEmptyString(Field(product, "@id")); ok { + return true + } + _, ok := NonEmptyString(Field(Field(product, "identifiers"), "purl")) + return ok +} + +// digestBound reports whether every identifier a product actually carries ends +// in the platform digest. An absent identifier passes; combined with +// identifiable above, that means every product names the manifest the +// statement is published against. +func digestBound(product any, digest string) bool { + for _, identifier := range []any{ + Field(product, "@id"), + Field(Field(product, "identifiers"), "purl"), + } { + text, ok := NonEmptyString(identifier) + if ok && !strings.HasSuffix(text, "@"+digest) { + return false + } + } + return true +} + +// hasSubcomponents reports whether a product names at least one subcomponent. +func hasSubcomponents(product any) bool { + list, ok := Field(product, "subcomponents").([]any) + return ok && len(list) > 0 +} + +// unversionedSubcomponents returns the identifiers of every subcomponent that +// is missing or carries no version, across all products. +func unversionedSubcomponents(products []any) []string { + var bare []string + for _, product := range products { + list, ok := Field(product, "subcomponents").([]any) + if !ok { + continue + } + for _, entry := range list { + identifier, ok := subcomponentIdentifier(entry) + if !ok { + bare = append(bare, "") + continue + } + if !purlHasVersion(identifier) { + bare = append(bare, identifier) + } + } + } + return bare +} + +// subcomponentIdentifier returns a subcomponent's purl, preferring +// identifiers.purl and falling back to @id, which OpenVEX allows to carry one. +func subcomponentIdentifier(entry any) (string, bool) { + if purl, ok := NonEmptyString(Field(Field(entry, "identifiers"), "purl")); ok { + return purl, true + } + return NonEmptyString(Field(entry, "@id")) +} + +// purlHasVersion reports whether a purl carries a version. purl puts the +// version between `@` and the first `?` or `#`, so the qualifiers and subpath +// are cut first: an `@` inside a qualifier value is not a version. +func purlHasVersion(identifier string) bool { + const prefix = "pkg:" + if !strings.HasPrefix(identifier, prefix) { + return false + } + rest := identifier[len(prefix):] + if at := strings.IndexAny(rest, "?#"); at >= 0 { + rest = rest[:at] + } + at := strings.Index(rest, "@") + return at >= 0 && at < len(rest)-1 +} + +// componentList reports whether a product's subcomponents, if it has any, are +// an array of Component objects. OpenVEX v0.2.0 types every entry as an object, +// and a scalar member marshals without complaint, so it would otherwise be +// copied verbatim into the signed projection. +func componentList(product any) bool { + value := Field(product, "subcomponents") + if value == nil { + return true + } + list, ok := value.([]any) + if !ok { + return false + } + for _, entry := range list { + if _, ok := entry.(map[string]any); !ok { + return false + } + } + return true +} + +func anyProduct(products []any, predicate func(any) bool) bool { + return slices.ContainsFunc(products, predicate) +} + +// anySet reports whether the statement carries at least one of the named +// fields as a non-empty string. +func anySet(statement map[string]any, keys ...string) bool { + for _, key := range keys { + if _, ok := NonEmptyString(statement[key]); ok { + return true + } + } + return false +} + +func inEnum(labels []string, value any) bool { + text, ok := value.(string) + return ok && slices.Contains(labels, text) +} + +// asJSON renders a rejected value the way it appeared in the document, so the +// message distinguishes a missing field from an empty string from a number. +func asJSON(value any) string { + encoded, err := json.Marshal(value) + if err != nil { + return fmt.Sprintf("%v", value) + } + return string(encoded) +} + +// errMissingMode is fatal rather than defaulted: defaulting would silently +// pick one of the two mode-specific behaviors, and the dangerous default is +// the one that skips the binding rule. +var errMissingMode = errors.New("-mode is required; expected source or projection") + +// validateOptions is the parsed `openvex validate` command line. +type validateOptions struct { + mode string + in string + digest string +} + +// RunValidate checks an OpenVEX v0.2.0 document against the contract the +// NodeWright release evidence path depends on, and fails closed rather than +// letting a release publish a VEX attestation no scanner can apply. It returns +// the process exit status rather than taking it: 0 when the document satisfies +// the contract, 1 when it does not, 2 on an unusable command line. +// +// Two documents pass through it and BOTH must be checked: the committed +// .openvex.json source, and each per-platform projection RunBind derives from +// it. Validating only the source would leave the documents that are actually +// signed unvalidated, since binding rewrites `products` on every statement it +// keeps. +// +// The rules live in one place shared by both modes so they cannot drift. +// Exactly one is mode-specific: in projection mode every product identifier +// must end in `@`. That is the one failure this command exists +// to prevent, a bare pkg:oci/ product no consumer can tie to a manifest, +// so projection mode requires the digest rather than treating it as optional. +// +// Usage: +// +// openvex validate -mode source -in .openvex.json +// openvex validate -mode projection -in amd64.openvex.json -digest sha256:<64 hex> +func RunValidate(args []string, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("openvex validate", flag.ContinueOnError) + flags.SetOutput(stderr) + + var o validateOptions + flags.StringVar(&o.mode, "mode", "", "which document is being checked: source or projection (required)") + flags.StringVar(&o.in, "in", "", "OpenVEX document to check (required)") + flags.StringVar(&o.digest, "digest", "", "platform manifest digest every product must be bound to, sha256:<64 hex> (required in projection mode)") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + return 0 + } + return 2 + } + + if err := runValidate(o, stdout); err != nil { + fmt.Fprintf(stderr, "openvex validate: %v\n", err) + return 1 + } + return 0 +} + +// runValidate checks one document and reports every problem it found. Problems +// are written to stdout as `::error::` lines so GitHub Actions surfaces each +// one against the step, and the returned error carries the exit status. +func runValidate(o validateOptions, stdout io.Writer) error { + mode, digest, err := resolveMode(o) + if err != nil { + return annotate(stdout, err) + } + + source, err := readDocument(mode, o.in) + if err != nil { + return annotate(stdout, err) + } + doc, err := Decode(source) + if err != nil { + switch { + case errors.Is(err, ErrNotObject): + return annotate(stdout, fmt.Errorf("OpenVEX %s document is not a JSON object: %s", mode, o.in)) + case errors.Is(err, ErrTrailingValue): + return annotate(stdout, fmt.Errorf("OpenVEX %s document holds more than one JSON value: %s", mode, o.in)) + default: + return annotate(stdout, fmt.Errorf("OpenVEX %s document is not valid JSON: %s", mode, o.in)) + } + } + + problems := Validate(doc, digest) + if len(problems) > 0 { + for _, problem := range problems { + if err := emit(stdout, fmt.Sprintf("OpenVEX %s document violates the v0.2.0 contract: %s", mode, problem)); err != nil { + return err + } + } + return fmt.Errorf("%s document %s violates the OpenVEX v0.2.0 contract in %d place(s)", mode, o.in, len(problems)) + } + + if _, err := fmt.Fprintf(stdout, "OpenVEX %s document %s satisfies the v0.2.0 contract\n", mode, o.in); err != nil { + return fmt.Errorf("writing the summary line: %w", err) + } + return nil +} + +// resolveMode returns the mode and the digest the rules run with. An unknown +// mode is itself a failure: defaulting it would silently pick one of the two +// mode-specific behaviors, and the dangerous default is the one that skips the +// binding rule. +func resolveMode(o validateOptions) (Mode, string, error) { + if o.in == "" { + return "", "", errMissingIn + } + switch Mode(o.mode) { + case ModeSource: + // A committed source carries bare pkg:oci/ products by design, + // so the binding rule is switched off rather than merely unsatisfied. + // A digest handed to source mode is rejected rather than ignored: the + // caller asked for a check this mode does not perform. + if o.digest != "" { + return "", "", errors.New("source mode does not take -digest; the committed document is not bound to a manifest") + } + return ModeSource, "", nil + case ModeProjection: + if !digestPattern.MatchString(o.digest) { + return "", "", fmt.Errorf("projection mode needs the platform digest as sha256:<64 hex>, got %q", o.digest) + } + return ModeProjection, o.digest, nil + case "": + return "", "", errMissingMode + default: + return "", "", fmt.Errorf("unknown mode %s; expected source or projection", o.mode) + } +} + +func readDocument(mode Mode, path string) ([]byte, error) { + info, err := os.Stat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + return nil, fmt.Errorf("OpenVEX %s document not found: %s", mode, path) + case err != nil: + return nil, fmt.Errorf("OpenVEX %s document cannot be read: %s: %w", mode, path, err) + case info.Size() > maxDocumentBytes: + return nil, fmt.Errorf("OpenVEX %s document %s is %d bytes, over the %d byte limit", mode, path, info.Size(), maxDocumentBytes) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("OpenVEX %s document cannot be read: %s: %w", mode, path, err) + } + if len(data) == 0 { + return nil, fmt.Errorf("OpenVEX %s document is empty: %s", mode, path) + } + return data, nil +} + +// annotate reports err as a workflow error before returning it, so a failure +// reaches the Actions log the same way a contract violation does. +func annotate(stdout io.Writer, err error) error { + if emitErr := emit(stdout, err.Error()); emitErr != nil { + return emitErr + } + return err +} + +func emit(stdout io.Writer, message string) error { + if _, err := fmt.Fprintf(stdout, "::error::%s\n", message); err != nil { + return fmt.Errorf("writing a workflow error annotation: %w", err) + } + return nil +} diff --git a/tools/internal/openvex/validate_test.go b/tools/internal/openvex/validate_test.go new file mode 100644 index 000000000..ff2c7f013 --- /dev/null +++ b/tools/internal/openvex/validate_test.go @@ -0,0 +1,610 @@ +// 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. + +package openvex + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +const ( + openVEXContext = "https://openvex.dev/ns/v0.2.0" + testAMD64Digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + testARM64Digest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + openVEXPath = ".openvex.json" +) + +// vexDocument builds a document that is valid apart from whatever the caller +// changes. extraFields is spliced in at the document level, statements is the +// literal contents of the statements array. +func vexDocument(extraFields, statements string) string { + return `{"@context": "` + openVEXContext + `", + "@id": "https://github.com/NVIDIA/nodewright/.openvex.json", + "author": "NVIDIA NodeWright maintainers", + "timestamp": "2026-09-16T00:00:00Z", + "version": 1` + extraFields + `, + "statements": [` + statements + `]}` +} + +// A statement carrying bare product identifiers, which is what a committed +// source document looks like: nothing has been bound to a manifest yet. +const unboundStatement = `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "identifiers": {"purl": "pkg:oci/operator"}, "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], + "status": "not_affected", "justification": "component_not_present"}` + +// The same statement after binding: every identifier names the platform +// manifest the claim is published against. +const boundStatement = `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator@` + testAMD64Digest + `", + "identifiers": {"purl": "pkg:oci/operator@` + testAMD64Digest + `"}, + "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], + "status": "not_affected", "justification": "component_not_present"}` + +// repoRoot walks up from the working directory until it finds the checkout +// root, recognized by holding both `.github` and `.openvex.json`. A hardcoded +// "../.." would silently start reading the wrong tree the moment this package +// moves a level. +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("working directory: %v", err) + } + for { + _, githubErr := os.Stat(filepath.Join(dir, ".github")) + _, vexErr := os.Stat(filepath.Join(dir, openVEXPath)) + if githubErr == nil && vexErr == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("no repository root above the working directory: looked for a directory holding both .github and %s", openVEXPath) + } + dir = parent + } +} + +// validateArgs renders the command line `openvex validate` is invoked with. +// Both flags are always passed, empty values included: an empty -mode and an +// empty -digest are cases the command has to answer for, and leaving the flag +// off would exercise the default instead. +func validateArgs(mode, in, digest string) []string { + return []string{"-mode", mode, "-in", in, "-digest", digest} +} + +func writeFile(t *testing.T, path, contents string) string { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} + +// TestValidateDocuments drives the command the release step runs, so what these +// cases accept is what ships signed. Each case is a whole invocation rather +// than a call into Validate, because mode resolution and document reading are +// as much of the contract as the rules are. +func TestValidateDocuments(t *testing.T) { + t.Parallel() + + longImpact := strings.Repeat("evidence. ", 60) + longTooling := strings.Repeat("t", 300) + + tests := []struct { + name string + mode string + // document is written to a temp file, empty content included, which is + // how the empty-file case is expressed. Set useCommitted to validate + // the repository's own .openvex.json instead, or missingFile to point + // at a path nothing wrote. + document string + useCommitted bool + missingFile bool + digest string + wantErr bool + wantMessage string + note string + }{ + { + name: "source accepts an empty statements array", + mode: "source", + document: vexDocument("", ""), + note: `NodeWright deliberately diverges from the sibling implementation in NVIDIA/aicr, which requires a non-empty statements array in source mode. +An empty array asserts that there are no exceptions to declare; it does not assert the images are clean. If this case now fails, someone restored the sibling's rule, which gates every NodeWright release on having a statement to write.`, + }, + { + name: "source accepts the committed document", + mode: "source", + useCommitted: true, + note: "the committed .openvex.json must pass the validator that runs against it at release time", + }, + { + name: "source accepts unbound products", + mode: "source", + document: vexDocument("", unboundStatement), + note: "a committed source carries bare pkg:oci/ products by design, so the binding rule is off in source mode", + }, + { + name: "source rejects a digest it would not use", + mode: "source", + document: vexDocument("", unboundStatement), + digest: testAMD64Digest, + wantErr: true, + wantMessage: "source mode does not take -digest", + note: "ignoring the digest would let a caller believe the binding rule ran when this mode never applies it", + }, + { + name: "projection accepts an empty projection", + mode: "projection", + document: vexDocument("", ""), + digest: testAMD64Digest, + note: "a source with no statements projects to a projection with no statements, which is still a document worth publishing", + }, + { + name: "projection accepts digest-bound products", + mode: "projection", + document: vexDocument("", boundStatement), + digest: testAMD64Digest, + }, + { + name: "projection rejects a product not bound to the platform digest", + mode: "projection", + document: vexDocument("", unboundStatement), + digest: testAMD64Digest, + wantErr: true, + wantMessage: "not bound to @" + testAMD64Digest, + note: "a bare pkg:oci/ product is one no consumer can tie to a manifest; this is the failure the whole command exists to prevent", + }, + { + name: "projection rejects a product bound to the other platform", + mode: "projection", + document: vexDocument("", boundStatement), + digest: testARM64Digest, + wantErr: true, + wantMessage: "not bound to @" + testARM64Digest, + }, + { + name: "projection rejects an identifiers.purl left unbound", + mode: "projection", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator@`+testAMD64Digest+`", + "identifiers": {"purl": "pkg:oci/operator"}}], + "status": "fixed"}`), + digest: testAMD64Digest, + wantErr: true, + wantMessage: "not bound to @" + testAMD64Digest, + note: "both identifiers are consulted; binding one and leaving the other bare still publishes an identifier nothing can match", + }, + { + name: "projection rejects a missing digest", + mode: "projection", + document: vexDocument("", boundStatement), + wantErr: true, + wantMessage: "projection mode needs the platform digest", + note: "a projection that cannot be checked against a digest is exactly the case this command exists to reject", + }, + { + name: "projection rejects a malformed digest", + mode: "projection", + document: vexDocument("", boundStatement), + digest: "sha256:not-a-digest", + wantErr: true, + wantMessage: "projection mode needs the platform digest", + }, + { + name: "an unknown mode is rejected rather than defaulted", + mode: "strict", + document: vexDocument("", unboundStatement), + wantErr: true, + wantMessage: "unknown mode strict", + note: "defaulting an unrecognized mode would silently pick one of the two mode-specific behaviors, and the dangerous default is the one that skips the binding rule", + }, + { + name: "an absent mode is rejected rather than defaulted", + document: vexDocument("", unboundStatement), + wantErr: true, + wantMessage: "-mode is required", + }, + { + name: "a missing file is rejected", + mode: "source", + missingFile: true, + wantErr: true, + wantMessage: "document not found", + }, + { + name: "an empty file is rejected", + mode: "source", + document: "", + wantErr: true, + wantMessage: "document is empty", + }, + { + name: "a file that is not JSON is rejected", + mode: "source", + document: "not json at all", + wantErr: true, + wantMessage: "is not valid JSON", + }, + { + name: "a JSON array is rejected", + mode: "source", + document: "[]", + wantErr: true, + wantMessage: "is not a JSON object", + note: "an array parses cleanly, so it has to be rejected by shape rather than by the JSON decoder", + }, + { + name: "a second JSON document is rejected", + mode: "source", + document: vexDocument("", "") + `{"@id": "second"}`, + wantErr: true, + wantMessage: "more than one JSON value", + note: "a decoder stops at the first value, so input this ambiguous would be silently truncated into signed evidence", + }, + { + name: "a downgraded @context is rejected", + mode: "source", + document: strings.Replace(vexDocument("", unboundStatement), openVEXContext, "https://openvex.dev/ns/v0.0.1", 1), + wantErr: true, + wantMessage: "@context must be " + openVEXContext, + note: "the @context equality check is what pins the spec version the enums describe", + }, + { + name: "a missing @id is rejected", + mode: "source", + document: `{"@context": "` + openVEXContext + `", "author": "a", "timestamp": "t", "version": 1, "statements": []}`, + wantErr: true, + wantMessage: "@id must be a non-empty string", + }, + { + name: "a blank author is rejected", + mode: "source", + document: `{"@context": "` + openVEXContext + `", "@id": "x", "author": " ", "timestamp": "t", "version": 1, "statements": []}`, + wantErr: true, + wantMessage: "author must be a non-empty string", + }, + { + name: "a missing timestamp is rejected", + mode: "source", + document: `{"@context": "` + openVEXContext + `", "@id": "x", "author": "a", "version": 1, "statements": []}`, + wantErr: true, + wantMessage: "timestamp must be a non-empty string", + }, + { + name: "a version that is a string is rejected", + mode: "source", + document: `{"@context": "` + openVEXContext + `", "@id": "x", "author": "a", "timestamp": "t", "version": "1", "statements": []}`, + wantErr: true, + wantMessage: "version must be a number", + }, + { + name: "statements that is not an array is rejected", + mode: "source", + document: `{"@context": "` + openVEXContext + `", "@id": "x", "author": "a", "timestamp": "t", "version": 1, "statements": {}}`, + wantErr: true, + wantMessage: "statements must be an array", + }, + { + name: "a document-level string over the byte bound is rejected", + mode: "source", + document: vexDocument(`, "tooling": "`+longTooling+`"`, unboundStatement), + wantErr: true, + wantMessage: "over the 256 byte bound", + note: "document-level strings are identifiers, not prose; a sibling project shipped an 8,010 byte tooling field signed on seven images", + }, + { + name: "a statement-level field of several hundred bytes is accepted", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "not_affected", + "impact_statement": "`+longImpact+`"}`), + note: "only document-level fields are bounded: impact_statement is evidence and is expected to run to paragraphs", + }, + { + name: "a statement that is not an object is rejected", + mode: "source", + document: vexDocument("", `"nope"`), + wantErr: true, + wantMessage: "statement 0 must be a JSON object", + }, + { + name: "a statement with no vulnerability name is rejected", + mode: "source", + document: vexDocument("", `{"products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], + "status": "fixed"}`), + wantErr: true, + wantMessage: "must set vulnerability.name to a non-empty string", + }, + { + name: "a statement with no products is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "status": "not_affected", "justification": "component_not_present"}`), + wantErr: true, + wantMessage: "must list at least one product", + note: "scanners match statements by products[].purl, so a statement with no products is unusable here", + }, + { + name: "a product with neither @id nor identifiers.purl is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"identifiers": {"cpe23": "cpe:2.3:a:nvidia:operator"}, "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], + "status": "fixed"}`), + wantErr: true, + wantMessage: "has a product with neither @id nor identifiers.purl", + }, + { + name: "subcomponents that are not an array are rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": "nope"}], + "status": "fixed"}`), + wantErr: true, + wantMessage: "subcomponents is not an array of objects", + }, + { + name: "a product with no subcomponents is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator"}], "status": "not_affected", + "justification": "component_not_present"}`), + wantErr: true, + wantMessage: "no subcomponents", + note: "a statement naming only the image asserts the whole image is unaffected, which is never what the assessment was about", + }, + { + name: "a subcomponent without a version is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", + "subcomponents": [{"@id": "pkg:pypi/pillow"}]}], "status": "not_affected", + "justification": "component_not_present"}`), + wantErr: true, + wantMessage: "without a version", + note: "a version-less subcomponent keeps suppressing after the package is upgraded, covering a version nobody assessed", + }, + { + name: "a version carried only inside a qualifier is not a version", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", + "subcomponents": [{"@id": "pkg:pypi/pillow?vcs_url=git+https://x/y@abc"}]}], + "status": "not_affected", "justification": "component_not_present"}`), + wantErr: true, + wantMessage: "without a version", + note: "purl puts the version before the qualifiers, so an @ inside a qualifier value must not count as one", + }, + { + name: "a versioned subcomponent carrying qualifiers is accepted", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", + "subcomponents": [{"@id": "pkg:deb/debian/libssl3t64@3.5.6-1~deb13u1?arch=arm64"}]}], + "status": "not_affected", "justification": "component_not_present"}`), + }, + { + name: "a subcomponent identified by identifiers.purl is accepted", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", + "subcomponents": [{"identifiers": {"purl": "pkg:pypi/pillow@12.2.0"}}]}], + "status": "not_affected", "justification": "component_not_present"}`), + }, + { + name: "a scalar subcomponent is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": ["nope"]}], + "status": "fixed"}`), + wantErr: true, + wantMessage: "subcomponents is not an array of objects", + note: "a scalar marshals without complaint and would be copied verbatim into the signed projection", + }, + { + name: "an array of subcomponent objects is accepted", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", + "subcomponents": [{"@id": "pkg:golang/stdlib@go1.27.1"}]}], + "status": "fixed"}`), + }, + { + name: "a status outside the four-label enum is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "probably_fine"}`), + wantErr: true, + wantMessage: "is not one of not_affected, affected, fixed, under_investigation", + }, + { + name: "a missing status is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}]}`), + wantErr: true, + wantMessage: "status null is not one of", + }, + { + name: "not_affected with neither justification nor impact statement is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "not_affected"}`), + wantErr: true, + wantMessage: "must carry a justification or an impact_statement", + }, + { + name: "not_affected carrying only an impact statement is accepted", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "not_affected", + "impact_statement": "the vulnerable path is unreachable from any entrypoint"}`), + }, + { + name: "a justification outside the five-label enum is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "not_affected", + "justification": "we looked and it seemed fine"}`), + wantErr: true, + wantMessage: "is not one of component_not_present", + }, + { + name: "a justification on a fixed statement is still enum-checked", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "fixed", + "justification": "we looked and it seemed fine"}`), + wantErr: true, + wantMessage: "is not one of component_not_present", + note: "a justification present at all must be one of the five labels, whatever status carries it", + }, + { + name: "affected without an action statement is rejected", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "affected"}`), + wantErr: true, + wantMessage: "must carry an action_statement", + }, + { + name: "affected with an action statement is accepted", + mode: "source", + document: vexDocument("", `{"vulnerability": {"name": "CVE-2026-0001"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "affected", + "action_statement": "upgrade to operator v0.16.0"}`), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + var path string + switch { + case test.useCommitted: + path = filepath.Join(repoRoot(t), openVEXPath) + case test.missingFile: + path = filepath.Join(t.TempDir(), "absent.openvex.json") + default: + path = writeFile(t, filepath.Join(t.TempDir(), "openvex.json"), test.document) + } + + var out, stderr bytes.Buffer + code := RunValidate(validateArgs(test.mode, path, test.digest), &out, &stderr) + output := out.String() + + if (code != 0) != test.wantErr { + t.Fatalf("RunValidate(-mode %s -digest %q) = %d, wantErr %t\n%s\n%s\n%s", + test.mode, test.digest, code, test.wantErr, output, stderr.String(), test.note) + } + if test.wantErr && code != 1 { + t.Errorf("RunValidate(-mode %s) = %d on a rejected document, want 1", test.mode, code) + } + if test.wantErr && !strings.Contains(stderr.String(), "openvex validate:") { + t.Errorf("rejection was not summarized on stderr: %q", stderr.String()) + } + if !test.wantErr && stderr.Len() != 0 { + t.Errorf("document was accepted but the command still wrote to stderr: %q", stderr.String()) + } + if test.wantErr && !strings.Contains(output, test.wantMessage) { + t.Errorf("rejection did not explain itself: want a message containing %q, got:\n%s\n%s", + test.wantMessage, output, test.note) + } + if test.wantErr && !strings.Contains(output, "::error::") { + t.Errorf("rejection was not reported as a workflow error annotation:\n%s", output) + } + if !test.wantErr && strings.Contains(output, "::error::") { + t.Errorf("document was accepted but the command still logged an error:\n%s", output) + } + }) + } +} + +// TestValidateReportsEveryProblem pins the "print every problem" behavior. A +// release that fails this check is fixed by hand, and one round trip per +// problem is a release held open. +func TestValidateReportsEveryProblem(t *testing.T) { + t.Parallel() + + document := vexDocument("", `{"vulnerability": {"name": ""}, "products": [], "status": "probably_fine"}, + {"vulnerability": {"name": "CVE-2026-0002"}, + "products": [{"@id": "pkg:oci/operator", "subcomponents": [{"@id": "pkg:golang/example.com/mod@v1.2.3"}]}], "status": "affected"}`) + path := writeFile(t, filepath.Join(t.TempDir(), "openvex.json"), document) + + var out bytes.Buffer + if code := RunValidate(validateArgs("source", path, ""), &out, &bytes.Buffer{}); code == 0 { + t.Fatalf("RunValidate() = 0, want a rejection:\n%s", out.String()) + } + + want := []string{ + "statement 0 must set vulnerability.name to a non-empty string", + "statement 0 must list at least one product", + `statement 0 status "probably_fine" is not one of`, + "statement 1 is affected and must carry an action_statement", + } + for _, message := range want { + if !strings.Contains(out.String(), message) { + t.Errorf("missing problem %q in:\n%s", message, out.String()) + } + } + if got := strings.Count(out.String(), "::error::"); got != len(want) { + t.Errorf("reported %d problems, want %d:\n%s", got, len(want), out.String()) + } +} + +// TestValidateIsDeterministic guards the sorted document-field scan. Go +// randomizes map iteration, so an unsorted scan would reorder its own output +// between runs on one unchanged document and read as a flake, not a finding. +func TestValidateIsDeterministic(t *testing.T) { + t.Parallel() + + long := strings.Repeat("t", 300) + document := vexDocument(`, "tooling": "`+long+`", "role": "`+long+`"`, "") + path := writeFile(t, filepath.Join(t.TempDir(), "openvex.json"), document) + + var first bytes.Buffer + if code := RunValidate(validateArgs("source", path, ""), &first, &bytes.Buffer{}); code == 0 { + t.Fatalf("RunValidate() = 0, want a rejection:\n%s", first.String()) + } + for i := 0; i < 20; i++ { + var next bytes.Buffer + if code := RunValidate(validateArgs("source", path, ""), &next, &bytes.Buffer{}); code == 0 { + t.Fatalf("RunValidate() = 0, want a rejection:\n%s", next.String()) + } + if next.String() != first.String() { + t.Fatalf("output is not stable across runs:\n%s\nvs\n%s", first.String(), next.String()) + } + } +} + +// TestRunValidateRejectsAnUnknownFlag pins that a bad command line returns a +// status rather than exiting the process, which is what lets every case above +// run in-process. Parse errors are reported on the writer the caller supplied, +// not on os.Stderr. +func TestRunValidateRejectsAnUnknownFlag(t *testing.T) { + t.Parallel() + var stdout, stderr bytes.Buffer + if code := RunValidate([]string{"-nope"}, &stdout, &stderr); code != 2 { + t.Errorf("RunValidate(-nope) = %d, want 2", code) + } + if !strings.Contains(stderr.String(), "-nope") { + t.Errorf("parse failure was not reported on the supplied stderr: %q", stderr.String()) + } +} diff --git a/tools/tests/go.mod b/tools/tests/go.mod new file mode 100644 index 000000000..0174c7c11 --- /dev/null +++ b/tools/tests/go.mod @@ -0,0 +1,5 @@ +module github.com/NVIDIA/nodewright/tools/tests + +go 1.27.1 + +require go.yaml.in/yaml/v3 v3.0.5 diff --git a/tools/tests/go.sum b/tools/tests/go.sum new file mode 100644 index 000000000..8675e7fd1 --- /dev/null +++ b/tools/tests/go.sum @@ -0,0 +1,2 @@ +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= diff --git a/tools/tests/releasepolicy/evidence_test.go b/tools/tests/releasepolicy/evidence_test.go new file mode 100644 index 000000000..5317e40b4 --- /dev/null +++ b/tools/tests/releasepolicy/evidence_test.go @@ -0,0 +1,492 @@ +// 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. + +package releasepolicy + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +var update = flag.Bool("update", false, "rewrite the evidence layout golden file from the current actions and workflows") + +const goldenPath = "testdata/evidence-layout.golden" + +// Column widths are fixed rather than computed from the widest row so that +// adding one operation changes one line of the golden instead of reflowing the +// whole file into an unreadable diff. A detail longer than the column simply +// overflows: the rows that run long are the openvex argument lists, and nothing +// follows them on the line. +const ( + goldenLabelWidth = 28 + goldenDetailWidth = 22 +) + +// evidenceOp is one operation the release path performs, reduced to what the +// policy is about: what it publishes or checks, against which subject, and +// under which flags. +type evidenceOp struct { + File string + Step string + Label string // "cosign attest cyclonedx", "openvex validate" + Subject string // digest variable the operation publishes against + Mode string // `openvex validate`'s -mode + Digest string // digest variable an openvex tool is handed + Bundle bool // --new-bundle-format=true is passed explicitly + Timeout string // the deadline the call is bounded by, empty when unbounded + Command string // the command line itself, for failure messages +} + +// shellScope resolves an expression against the assignments a step's script +// makes, leaving variables the script does not assign as written. Those +// untouched names are the step's `env:`, which is where the action ties a name +// to an input or to another step's output, and they are what both the golden +// and the subject assertions are written against. +type shellScope struct { + bindings map[string]string +} + +var ( + // caseArm matches a `) ...` case arm, the only place these scripts + // bind a variable conditionally. + caseArm = regexp.MustCompile(`^([A-Za-z0-9_*]+)\)\s`) + // assignment matches a simple `name="value"` shell assignment. + assignment = regexp.MustCompile(`(?:^|[\s;])([A-Za-z_][A-Za-z0-9_]*)="([^"]*)"`) + // varReference matches a plain `${NAME}`. Parameter expansions such as + // `${INDEX_DIGEST#sha256:}` deliberately do not match: nothing a subject + // resolves through uses one, and half-expanding one would be worse than + // leaving it alone. + varReference = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`) +) + +func newShellScope(script, arch string) shellScope { + bindings := map[string]string{} + if arch != "" { + bindings["arch"] = arch + } + for _, line := range strings.Split(script, "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + // A case arm binds only under the architecture it selects. Without this + // the last arm would win and every per-platform subject would resolve + // to arm64. + if arm := caseArm.FindStringSubmatch(trimmed); arm != nil { + if arm[1] != "*" && arm[1] != arch { + continue + } + trimmed = strings.TrimSpace(trimmed[len(arm[0]):]) + } + for _, match := range assignment.FindAllStringSubmatch(trimmed, -1) { + // A command substitution is not a value this resolver can carry, + // and the regex would capture it truncated at its first inner + // quote. Leaving the name unbound renders it as itself, which is + // honest; a truncated value would not be. + if strings.Contains(match[2], "$(") { + continue + } + bindings[match[1]] = match[2] + } + } + return shellScope{bindings: bindings} +} + +func (s shellScope) expand(expression string) string { + return s.expandDepth(expression, 0) +} + +func (s shellScope) expandDepth(expression string, depth int) string { + if depth >= 10 { + return expression + } + return varReference.ReplaceAllStringFunc(expression, func(reference string) string { + name := varReference.FindStringSubmatch(reference)[1] + value, assigned := s.bindings[name] + if !assigned { + return reference + } + return s.expandDepth(value, depth+1) + }) +} + +// digestRef reduces a resolved OCI reference to the part after its final `@`, +// which is the subject the evidence hangs on, and strips `${}` so the result is +// the bare variable name the policy is written in terms of. +func digestRef(reference string) string { + reference = strings.Trim(reference, `"`) + if at := strings.LastIndex(reference, "@"); at >= 0 { + reference = reference[at+1:] + } + if match := varReference.FindStringSubmatch(reference); match != nil && match[0] == reference { + return match[1] + } + return reference +} + +// segment is a slice of a step's script together with the scope its commands +// resolve in. +type segment struct { + arch string + body string + scope shellScope +} + +var archLoopStart = regexp.MustCompile(`^for arch in ([a-z0-9 ]+); do$`) + +// unroll splits a step's script into the part that runs once and the part that +// runs once per architecture. The per-platform work is a literal +// `for arch in amd64 arm64` loop, and what this package is about is the layout +// as published rather than as written, so the loop is unrolled here: each +// platform's evidence gets its own operation. +func unroll(script string) []segment { + lines := strings.Split(script, "\n") + start, arches := -1, []string(nil) + for i, line := range lines { + if match := archLoopStart.FindStringSubmatch(strings.TrimSpace(line)); match != nil { + start, arches = i, strings.Fields(match[1]) + break + } + } + if start < 0 { + return []segment{{body: script, scope: newShellScope(script, "")}} + } + + end := len(lines) + for i := start + 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "done" && leadingSpaces(lines[i]) == leadingSpaces(lines[start]) { + end = i + break + } + } + preamble := strings.Join(lines[:start], "\n") + body := strings.Join(lines[start+1:end], "\n") + tail := "" + if end+1 < len(lines) { + tail = strings.Join(lines[end+1:], "\n") + } + + segments := []segment{{body: preamble, scope: newShellScope(preamble, "")}} + for _, arch := range arches { + segments = append(segments, segment{ + arch: arch, + body: body, + scope: newShellScope(preamble+"\n"+body, arch), + }) + } + return append(segments, segment{body: tail, scope: newShellScope(preamble+"\n"+tail, "")}) +} + +func lastField(command string) string { + fields := strings.Fields(command) + if len(fields) == 0 { + return "" + } + return fields[len(fields)-1] +} + +func flagValue(command, flag string) string { + fields := strings.Fields(command) + for i, field := range fields { + if field == flag && i+1 < len(fields) { + return strings.Trim(fields[i+1], `"`) + } + } + return "" +} + +// classify turns a command line into the operation it performs, or reports that +// it performs none. Anything that neither publishes evidence nor validates it +// is not part of the layout. +// openvexIs reports whether a command runs the given ./cmd/openvex subcommand. +func openvexIs(command, subcommand string) bool { + _, got, ok := openvexSubcommand(command) + return ok && got == subcommand +} + +func classify(command string, scope shellScope) (evidenceOp, bool) { + op := evidenceOp{Command: command} + switch { + case invokes(command, "cosign sign"): + op.Label = "cosign sign" + op.Subject = digestRef(scope.expand(lastField(command))) + op.Timeout, _ = timeoutFor(command, "cosign sign") + case invokes(command, "cosign attest"): + op.Label = "cosign attest " + flagValue(command, "--type") + op.Subject = digestRef(scope.expand(lastField(command))) + op.Timeout, _ = timeoutFor(command, "cosign attest") + case openvexIs(command, "validate"): + op.Label = "openvex validate" + op.Mode = flagValue(command, "-mode") + op.Digest = digestRef(scope.expand(flagValue(command, "-digest"))) + case openvexIs(command, "bind"): + op.Label = "openvex bind" + op.Digest = digestRef(scope.expand(flagValue(command, "-digest"))) + default: + return evidenceOp{}, false + } + op.Bundle = hasFlag(command, "--new-bundle-format=true") + return op, true +} + +// actionOps returns every evidence operation a composite action performs, in +// published order. +func actionOps(action CompositeAction) []evidenceOp { + var ops []evidenceOp + for _, step := range action.Runs.Steps { + for _, part := range unroll(step.Run) { + for _, command := range shellCommands(part.body) { + op, ok := classify(command, part.scope) + if !ok { + continue + } + op.File, op.Step = action.path, step.Name + ops = append(ops, op) + } + } + } + return ops +} + +// attestations returns the operations publishing SBOM and VEX predicates. +func attestations(ops []evidenceOp) []evidenceOp { + var matched []evidenceOp + for _, op := range ops { + if strings.HasPrefix(op.Label, "cosign attest") { + matched = append(matched, op) + } + } + return matched +} + +// signatures returns the operations publishing a cosign signature. +func signatures(ops []evidenceOp) []evidenceOp { + var matched []evidenceOp + for _, op := range ops { + if op.Label == "cosign sign" { + matched = append(matched, op) + } + } + return matched +} + +// validations returns the `openvex validate` operations, in run order. +func validations(ops []evidenceOp) []evidenceOp { + var matched []evidenceOp + for _, op := range ops { + if op.Label == "openvex validate" { + matched = append(matched, op) + } + } + return matched +} + +// evidenceWiring is one digest a workflow hands to an evidence action. The +// actions decide what hangs where; only the workflow decides what they are +// given, so a subject can move without any action changing. +type evidenceWiring struct { + Action string + Input string + Value string +} + +var wiringActions = []struct { + reference string + label string + inputs []string +}{ + {"./.github/actions/cosign-attest-multiplatform", "cosign-attest-multiplatform", []string{"index-digest"}}, + {"./.github/actions/cosign-sign-sbom", "cosign-sign-sbom", []string{"subject-digest"}}, + {"actions/attest-build-provenance", "attest-build-provenance", []string{"subject-digest"}}, + {"./.github/actions/cosign-verify-release", "cosign-verify-release", []string{"subject-digest", "amd64-digest", "arm64-digest"}}, +} + +func workflowWiring(workflow Workflow) []evidenceWiring { + var rows []evidenceWiring + for _, step := range workflow.steps() { + for _, candidate := range wiringActions { + if !strings.HasPrefix(step.Uses, candidate.reference) { + continue + } + for _, input := range candidate.inputs { + if value, present := step.With[input]; present { + rows = append(rows, evidenceWiring{Action: candidate.label, Input: input, Value: value}) + } + } + break + } + } + return rows +} + +type digestBinding struct { + name string + value string +} + +// digestBindings reports where an action's digest variables come from. Without +// them the subjects below would pin nothing but names: a step could set +// AMD64_DIGEST from the index input and still read as per-platform. +func digestBindings(action CompositeAction) []digestBinding { + var bindings []digestBinding + seen := map[string]bool{} + for _, step := range action.Runs.Steps { + for _, name := range []string{indexDigestVar, subjectDigestVar, amd64DigestVar, arm64DigestVar} { + value, present := step.Env[name] + if !present || seen[name] { + continue + } + seen[name] = true + bindings = append(bindings, digestBinding{name: name, value: value}) + } + } + return bindings +} + +const goldenHeader = `# The evidence layout this release path publishes, rendered from the composite +# actions and workflows that publish it. Regenerate with: +# +# cd tools/tests && go test ./releasepolicy -update +# +# Read a diff here as a claim about what a release publishes. A line whose arrow +# target moved between the index digest and a platform digest is the bug this +# package exists to catch, and regenerating the golden would hide it. That is +# why the subject rules, the cosign flags and the version pins are ALSO asserted +# by hand in release_workflow_test.go, where -update cannot reach them. +# +# Subjects are shown as the shell variable carrying the digest; the env lines +# under each action say where that variable comes from. The per-platform +# ` + "`for arch`" + ` loops are unrolled, so each platform's evidence gets its own line. +# +# cosign-verify-release is not rendered: its cosign calls take their subject +# through a shell function parameter, which this renderer does not follow. The +# workflow sections show which digests each workflow hands it. +` + +func renderOp(op evidenceOp) string { + var attributes []string + if op.Bundle { + attributes = append(attributes, "--new-bundle-format=true") + } + if op.Timeout != "" { + attributes = append(attributes, "timeout "+op.Timeout) + } + + detail := "" + switch { + case op.Subject != "": + detail = "-> @" + op.Subject + default: + if op.Mode != "" { + detail = "-mode " + op.Mode + } + if op.Digest != "" { + detail = strings.TrimSpace(detail + " -digest @" + op.Digest) + } + } + + line := fmt.Sprintf("%-*s %-*s", goldenLabelWidth, op.Label, goldenDetailWidth, detail) + if len(attributes) > 0 { + line += "[" + strings.Join(attributes, ", ") + "]" + } + return strings.TrimRight(line, " ") + "\n" +} + +func renderEvidenceLayout(t *testing.T) string { + t.Helper() + var out strings.Builder + out.WriteString(goldenHeader) + + for _, path := range []string{attestActionPath, chartSignActionPath} { + action := loadAction(t, path) + fmt.Fprintf(&out, "\n== %s ==\n", path) + for _, binding := range digestBindings(action) { + fmt.Fprintf(&out, "env %-*s = %s\n", goldenLabelWidth-4, binding.name, binding.value) + } + out.WriteString("\n") + for _, op := range actionOps(action) { + out.WriteString(renderOp(op)) + } + } + + for _, path := range []string{operatorCIPath, agentCIPath, releasePath} { + fmt.Fprintf(&out, "\n== %s ==\n", path) + for _, row := range workflowWiring(loadWorkflow(t, path)) { + fmt.Fprintf(&out, "%-*s %-14s -> %s\n", goldenLabelWidth, row.Action, row.Input, row.Value) + } + } + return out.String() +} + +// lineDiff names the lines that differ rather than printing both copies of the +// layout. It compares by position, so an inserted line reports everything after +// it as changed; the first reported line is still the one to read. +func lineDiff(want, got string) string { + wantLines := strings.Split(want, "\n") + gotLines := strings.Split(got, "\n") + var out strings.Builder + for i := 0; i < len(wantLines) || i < len(gotLines); i++ { + wantLine, gotLine := "", "" + if i < len(wantLines) { + wantLine = wantLines[i] + } + if i < len(gotLines) { + gotLine = gotLines[i] + } + if wantLine == gotLine { + continue + } + fmt.Fprintf(&out, " line %d:\n golden: %s\n actual: %s\n", i+1, wantLine, gotLine) + } + return out.String() +} + +// TestEvidenceLayoutMatchesGolden renders the whole published layout into one +// artifact so a subject moving is visible as a diff rather than as an absent +// assertion. It deliberately does not carry the subject rules themselves: its +// repair mechanism is -update, and a rule a refactor can regenerate away is not +// a rule. Those live in release_workflow_test.go. +func TestEvidenceLayoutMatchesGolden(t *testing.T) { + rendered := renderEvidenceLayout(t) + path := filepath.FromSlash(goldenPath) + + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("create %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, []byte(rendered), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + t.Logf("wrote %s", path) + return + } + + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v\nregenerate it with `go test ./releasepolicy -update`", path, err) + } + if string(want) != rendered { + t.Errorf(`the published evidence layout no longer matches %s: +%s +If this change is intended, regenerate with `+"`go test ./releasepolicy -update`"+` and read the +diff as a change to what a release publishes. An arrow target that moved between +the index digest and a platform digest is not a stale golden.`, path, lineDiff(string(want), rendered)) + } +} diff --git a/tools/tests/releasepolicy/helpers_test.go b/tools/tests/releasepolicy/helpers_test.go new file mode 100644 index 000000000..25bca0cf0 --- /dev/null +++ b/tools/tests/releasepolicy/helpers_test.go @@ -0,0 +1,474 @@ +// 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. + +// Package releasepolicy pins the release evidence policy: which subject each +// piece of container image evidence is published against, how cosign and crane +// are invoked, and that both OpenVEX documents are validated before anything is +// signed. The policy is spread across two composite actions and three +// workflows, so a refactor that moves a subject or drops a flag looks local and +// is not. The OpenVEX rules themselves are unit-tested in +// tools/internal/openvex; what is pinned here is that the action still calls +// the validator, in both modes. +// +// This package is a Go module of its own. The parent tools module has no +// third-party dependencies, which is what lets the release job run +// `go run ./cmd/openvex bind` with a toolchain and no module download, and a +// directory holding its own go.mod is invisible to the parent's package +// pattern. The tests therefore read these files with a real YAML parser without +// putting a dependency anywhere near the release path. +// +// The published layout as a whole is pinned by a golden file, rendered in +// evidence_test.go. The rules that must NOT be repairable by regenerating a +// golden -- which subject each attestation targets, which flags every cosign +// call carries, which cosign version each file installs -- are asserted by hand +// in release_workflow_test.go instead. +package releasepolicy + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "go.yaml.in/yaml/v3" +) + +// Repository-relative paths of everything the policy lives in. +const ( + attestActionPath = ".github/actions/cosign-attest-multiplatform/action.yml" + verifyActionPath = ".github/actions/cosign-verify-release/action.yml" + // The Helm chart's single-subject signing path. It is not part of the + // per-platform split, but it shares the cosign pin and the bundle format + // with the actions that are, and it is verified by the same verify action. + chartSignActionPath = ".github/actions/cosign-sign-sbom/action.yml" + operatorCIPath = ".github/workflows/operator-ci.yaml" + agentCIPath = ".github/workflows/agent-ci.yaml" + releasePath = ".github/workflows/release.yml" + openVEXPath = ".openvex.json" +) + +// Shell variables the actions use to carry each kind of digest. The subject +// assertions are written against these names because the digests themselves are +// only known at release time. +const ( + indexDigestVar = "INDEX_DIGEST" + amd64DigestVar = "AMD64_DIGEST" + arm64DigestVar = "ARM64_DIGEST" + subjectDigestVar = "SUBJECT_DIGEST" +) + +// Step is the subset of a GitHub Actions step this policy reads. Both a +// composite action's `runs.steps` and a workflow job's `steps` decode into it. +type Step struct { + Name string `yaml:"name"` + ID string `yaml:"id"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + With stringMap `yaml:"with"` + Env stringMap `yaml:"env"` +} + +// stringMap decodes a `with:` or `env:` mapping, taking every value as the +// scalar text the author wrote. A plain map[string]string cannot: GitHub +// Actions inputs are typed by YAML, so `push-to-registry: true` decodes as a +// bool and fails the whole document. +type stringMap map[string]string + +func (m *stringMap) UnmarshalYAML(node *yaml.Node) error { + *m = stringMap{} + for i := 0; i+1 < len(node.Content); i += 2 { + (*m)[node.Content[i].Value] = node.Content[i+1].Value + } + return nil +} + +// ActionInput is a composite action's declared input. Only the default matters +// here: it is where the cosign version pin lives. +type ActionInput struct { + Description string `yaml:"description"` + Default string `yaml:"default"` +} + +// CompositeAction is an action.yml. +type CompositeAction struct { + Name string `yaml:"name"` + Inputs map[string]ActionInput `yaml:"inputs"` + Runs struct { + Using string `yaml:"using"` + Steps Steps `yaml:"steps"` + } `yaml:"runs"` + + // path is the repository-relative path it was read from, so failure + // messages can name the file without the caller repeating it. + path string +} + +// Job is one workflow job. +type Job struct { + Name string `yaml:"name"` + Steps Steps `yaml:"steps"` +} + +// Workflow is a workflow YAML file. +type Workflow struct { + Name string `yaml:"name"` + Jobs jobList `yaml:"jobs"` + + path string +} + +// NamedJob keeps a job together with its id. +type NamedJob struct { + ID string + Job Job +} + +// jobList decodes `jobs:` in document order. A map would decode fine and then +// iterate randomly, which would reshuffle the golden file on every run. +type jobList []NamedJob + +func (l *jobList) UnmarshalYAML(node *yaml.Node) error { + for i := 0; i+1 < len(node.Content); i += 2 { + var job Job + if err := node.Content[i+1].Decode(&job); err != nil { + return err + } + *l = append(*l, NamedJob{ID: node.Content[i].Value, Job: job}) + } + return nil +} + +// steps returns every step the workflow declares, in file order. +func (w Workflow) steps() Steps { + var all Steps + for _, job := range w.Jobs { + all = append(all, job.Job.Steps...) + } + return all +} + +// Steps is a step list with the lookups these assertions need. +type Steps []Step + +// named returns the single step carrying this name, failing the test when there +// is no such step: a renamed step silently dropping an assertion is the failure +// mode worth making loud. +func (s Steps) named(t *testing.T, name string) Step { + t.Helper() + for _, step := range s { + if step.Name == name { + return step + } + } + t.Fatalf("no step named %q", name) + return Step{} +} + +// using returns every step whose `uses:` starts with the given reference, which +// is how a pinned action (`owner/action@sha`) is matched by name alone. +func (s Steps) using(reference string) Steps { + var matched Steps + for _, step := range s { + if strings.HasPrefix(step.Uses, reference) { + matched = append(matched, step) + } + } + return matched +} + +// ids returns every step id the list declares. +func (s Steps) ids() map[string]bool { + declared := map[string]bool{} + for _, step := range s { + if step.ID != "" { + declared[step.ID] = true + } + } + return declared +} + +func loadAction(t *testing.T, relative string) CompositeAction { + t.Helper() + action := CompositeAction{path: relative} + if err := yaml.Unmarshal([]byte(readRepoFile(t, relative)), &action); err != nil { + t.Fatalf("parse %s: %v", relative, err) + } + if len(action.Runs.Steps) == 0 { + t.Fatalf("%s declares no steps", relative) + } + return action +} + +func loadWorkflow(t *testing.T, relative string) Workflow { + t.Helper() + workflow := Workflow{path: relative} + if err := yaml.Unmarshal([]byte(readRepoFile(t, relative)), &workflow); err != nil { + t.Fatalf("parse %s: %v", relative, err) + } + if len(workflow.Jobs) == 0 { + t.Fatalf("%s declares no jobs", relative) + } + return workflow +} + +// repoRoot walks up from the working directory until it finds the checkout +// root, recognized by holding both `.github` and `.openvex.json`. A hardcoded +// "../../.." would silently start reading the wrong tree the moment this +// package moves a level. +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatalf("working directory: %v", err) + } + for { + _, githubErr := os.Stat(filepath.Join(dir, ".github")) + _, vexErr := os.Stat(filepath.Join(dir, openVEXPath)) + if githubErr == nil && vexErr == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatalf("no repository root above the working directory: looked for a directory holding both .github and %s", openVEXPath) + } + dir = parent + } +} + +// readRepoFile returns the contents of a repository-relative path. +func readRepoFile(t *testing.T, relative string) string { + t.Helper() + path := filepath.Join(repoRoot(t), relative) + contents, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", relative, err) + } + return string(contents) +} + +func leadingSpaces(line string) int { + return len(line) - len(strings.TrimLeft(line, " ")) +} + +// shellCommands flattens a run body into logical command lines: backslash +// continuations are joined, whitespace is collapsed, and comment lines are +// dropped. Comments matter here because the actions discuss `cosign attest` in +// prose right next to the invocations, and a prose mention is not a command. +func shellCommands(script string) []string { + var commands []string + var current strings.Builder + for _, line := range strings.Split(script, "\n") { + trimmed := strings.TrimSpace(line) + if current.Len() == 0 && (trimmed == "" || strings.HasPrefix(trimmed, "#")) { + continue + } + if strings.HasSuffix(trimmed, `\`) { + current.WriteString(strings.TrimSuffix(trimmed, `\`)) + current.WriteString(" ") + continue + } + current.WriteString(trimmed) + commands = append(commands, strings.Join(strings.Fields(current.String()), " ")) + current.Reset() + } + if current.Len() > 0 { + commands = append(commands, strings.Join(strings.Fields(current.String()), " ")) + } + return commands +} + +// commandsInvoking returns every logical command line running the given +// program, across every step of a composite action or workflow. +// timeoutBefore reports the duration of a `timeout` wrapper ending immediately +// before position i, if there is one. The token holding `timeout` is matched by +// suffix because a wrapped call is often inside a substitution, which leaves it +// glued to what precedes it, for example `digest="$(timeout`. +func timeoutBefore(tokens []string, i int) (duration string, at int, ok bool) { + j := i - 1 + if j < 0 { + return "", 0, false + } + duration = tokens[j] + for j--; j >= 0 && strings.HasPrefix(tokens[j], "-"); j-- { + } + if j < 0 || !strings.HasSuffix(tokens[j], "timeout") { + return "", 0, false + } + return duration, j, true +} + +// commandIntroducers are the tokens after which the next token is an executable +// rather than an argument. +var commandIntroducers = map[string]bool{ + "if": true, "!": true, "then": true, "else": true, "elif": true, + "do": true, "while": true, "until": true, + "|": true, "||": true, "&&": true, ";": true, "{": true, "(": true, "$(": true, +} + +// commandPosition reports whether tokens[i] sits where a shell would take an +// executable. Matching a program anywhere in the line would count one that is +// only named, for example inside an `echo "::error::... cosign attest ..."`, +// and a bare first-token check would miss the real calls, which are wrapped in +// `timeout` and nested inside `if ! x="$( ... )"`. +func commandPosition(tokens []string, i int) bool { + if i == 0 { + return true + } + // The wrapper only confers a command position if it is in one itself. + // Without that, `echo timeout 120s cosign sign` reads as a real call: the + // tokens are identical to one, and only the enclosing executable says + // otherwise. Recursion terminates because the wrapper is always earlier. + if _, at, wrapped := timeoutBefore(tokens, i); wrapped && commandPosition(tokens, at) { + return true + } + previous := tokens[i-1] + if commandIntroducers[previous] { + return true + } + return strings.HasSuffix(previous, "$(") || strings.HasSuffix(previous, "(") +} + +// invocationIndex returns the token index at which command actually runs +// program, matching on whole tokens from a command position. +func invocationIndex(command, program string) (int, bool) { + want := strings.Fields(program) + tokens := strings.Fields(command) + for i := 0; i+len(want) <= len(tokens); i++ { + matched := true + for offset, token := range want { + if tokens[i+offset] != token { + matched = false + break + } + } + if matched && commandPosition(tokens, i) { + return i, true + } + } + return 0, false +} + +// invokes reports whether a command runs program. Token matching also keeps +// `cosign verify` from matching `cosign verify-attestation`: different +// commands, reading different things. +func invokes(command, program string) bool { + _, ok := invocationIndex(command, program) + return ok +} + +// openvexSubcommand returns the `./cmd/openvex` subcommand a command runs, if +// it runs one. The module directory travels with the command as `go -C ` +// rather than through a preceding `cd`, so a single command carries everything +// this package needs to check: which subcommand, and where it runs. +func openvexSubcommand(command string) (directory, subcommand string, ok bool) { + if !invokes(command, "go") { + return "", "", false + } + tokens := strings.Fields(command) + for i, token := range tokens { + if token == "-C" && i+1 < len(tokens) { + directory = strings.Trim(tokens[i+1], `"`) + } + if token == "./cmd/openvex" && i+1 < len(tokens) { + return directory, tokens[i+1], true + } + } + return "", "", false +} + +// hasFlag reports whether a command carries flag as a whole argument. A +// substring test would accept `--new-bundle-format=true-invalid`. +func hasFlag(command, flag string) bool { + for _, token := range strings.Fields(command) { + if token == flag { + return true + } + } + return false +} + +func commandsInvoking(steps Steps, program string) []string { + var matched []string + for _, step := range steps { + for _, command := range shellCommands(step.Run) { + if invokes(command, program) { + matched = append(matched, command) + } + } + } + return matched +} + +// timeoutFor returns the deadline program is invoked under on this command +// line, and whether it is bounded at all. +func timeoutFor(command, program string) (string, bool) { + at, ok := invocationIndex(command, program) + if !ok { + return "", false + } + duration, _, bounded := timeoutBefore(strings.Fields(command), at) + return duration, bounded +} + +func timeoutWrapped(command, program string) bool { + _, bounded := timeoutFor(command, program) + return bounded +} + +// bashWithAssociativeArrays finds a bash that can run the action's scripts. +// The platform resolution step uses `declare -A`, which bash 3.2 (still +// /bin/bash on macOS) does not have; GitHub's runners ship bash 5. +func bashWithAssociativeArrays(t *testing.T) string { + t.Helper() + candidates := []string{"bash", "/opt/homebrew/bin/bash", "/usr/local/bin/bash", "/bin/bash"} + for _, candidate := range candidates { + path, err := exec.LookPath(candidate) + if err != nil { + continue + } + if err := exec.Command(path, "-c", "declare -A probe=([k]=v); [ \"${probe[k]}\" = v ]").Run(); err == nil { + return path + } + } + t.Skip("no bash with associative array support found; the action's scripts need bash 4 or newer") + return "" +} + +// requireJQ skips when jq is absent. The resolution script pipes +// `crane manifest` into jq under `set -euo pipefail`, so on a host without it +// the script fails for a reason that has nothing to do with the policy under +// test. Skipping loudly is the honest outcome; a silent pass would not be. +func requireJQ(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("jq"); err != nil { + t.Skip("jq not found; the action's platform resolution pipes crane manifest into jq") + } +} + +func writeExecutable(t *testing.T, path, contents string) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o700); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func writeFile(t *testing.T, path, contents string) string { + t.Helper() + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + return path +} diff --git a/tools/tests/releasepolicy/invokes_test.go b/tools/tests/releasepolicy/invokes_test.go new file mode 100644 index 000000000..60895ea69 --- /dev/null +++ b/tools/tests/releasepolicy/invokes_test.go @@ -0,0 +1,146 @@ +// 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. + +package releasepolicy + +import "testing" + +// Every subject, flag and timeout assertion in this package is only as good as +// the matcher underneath it, so the matcher is tested rather than trusted. A +// substring match would count a command that merely names the program, which +// would let `echo "... cosign attest ..."` satisfy a check that no real +// attestation satisfies. +func TestInvokesMatchesCommandPositionsOnly(t *testing.T) { + t.Parallel() + tests := []struct { + name string + command string + program string + want bool + }{ + { + name: "a bare invocation", + command: `cosign sign --yes "${SUBJECT_NAME}@${INDEX_DIGEST}"`, + program: "cosign sign", + want: true, + }, + { + name: "wrapped in timeout", + command: `timeout --foreground 120s cosign attest --yes --type openvex "${subject}"`, + program: "cosign attest", + want: true, + }, + { + // The shape the real resolution step uses. + name: "nested in a conditional command substitution", + command: `if ! digest="$(timeout --foreground 120s crane digest --platform "${platform}" "${subject}")"; then`, + program: "crane digest", + want: true, + }, + { + name: "piped", + command: `timeout --foreground 120s crane manifest "${subject}" | jq -r '.manifests[]?'`, + program: "crane manifest", + want: true, + }, + { + name: "merely named in an error message", + command: `echo "::error::cosign attest failed for ${subject}"`, + program: "cosign attest", + want: false, + }, + { + name: "merely named in a comment-like echo of a command", + command: `echo timeout --foreground 120s cosign sign --new-bundle-format=true`, + program: "cosign sign", + want: false, + }, + { + // The reason the bundle-format sweep names both forms explicitly. + name: "verify does not match verify-attestation", + command: `timeout --foreground 120s cosign verify-attestation --type openvex "${target}"`, + program: "cosign verify", + want: false, + }, + { + name: "verify-attestation matches itself", + command: `timeout --foreground 120s cosign verify-attestation --type openvex "${target}"`, + program: "cosign verify-attestation", + want: true, + }, + { + name: "a different subcommand is not a match", + command: `timeout --foreground 120s cosign attest --type cyclonedx "${subject}"`, + program: "cosign sign", + want: false, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := invokes(test.command, test.program); got != test.want { + t.Errorf("invokes(%q, %q) = %t, want %t", test.command, test.program, got, test.want) + } + }) + } +} + +func TestTimeoutForResolvesTheWrapperOfTheMatchedCall(t *testing.T) { + t.Parallel() + tests := []struct { + name string + command string + program string + duration string + bounded bool + }{ + { + name: "a wrapped call reports its duration", + command: `timeout --foreground 120s cosign attest --yes "${subject}"`, + program: "cosign attest", + duration: "120s", + bounded: true, + }, + { + name: "a wrapper inside a substitution still counts", + command: `if ! digest="$(timeout --foreground 120s crane digest --platform "${platform}" "${subject}")"; then`, + program: "crane digest", + duration: "120s", + bounded: true, + }, + { + name: "an unwrapped call is unbounded", + command: `cosign attest --yes "${subject}"`, + program: "cosign attest", + }, + { + // A timeout somewhere else on the line must not be credited to this + // call, which is what a "first substring wins" rule would do. + name: "a timeout wrapping a different command does not count", + command: `timeout --foreground 120s crane digest "${subject}" && cosign attest --yes "${subject}"`, + program: "cosign attest", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + duration, bounded := timeoutFor(test.command, test.program) + if bounded != test.bounded || duration != test.duration { + t.Errorf("timeoutFor(%q, %q) = (%q, %t), want (%q, %t)", + test.command, test.program, duration, bounded, test.duration, test.bounded) + } + }) + } +} diff --git a/tools/tests/releasepolicy/openvex_products_test.go b/tools/tests/releasepolicy/openvex_products_test.go new file mode 100644 index 000000000..919701394 --- /dev/null +++ b/tools/tests/releasepolicy/openvex_products_test.go @@ -0,0 +1,155 @@ +// 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. + +package releasepolicy + +import ( + "encoding/json" + "regexp" + "sort" + "strings" + "testing" +) + +// A statement whose product names no released image is the one VEX mistake +// nothing downstream can catch. The release job cannot catch it: it only knows +// its own image, so "this statement is for the other image" and "this statement +// has a typo" look identical from there, and both produce an empty projection +// that is valid OpenVEX. The scanner cannot catch it either, because grype +// matches by product purl and simply applies nothing. +// +// The set of released images is only knowable from the workflows, so the check +// lives here, where it fails in the pull request that writes the bad statement +// rather than at a release months later. + +// subjectNameAssignment matches the shell line each image workflow uses to +// publish its repository to later steps, for example: +// +// echo "subject-name=${REGISTRY}/${IMAGE_NAME}/operator" >> $GITHUB_OUTPUT +// +// The final path segment is the basename grype derives its pkg:oci product purl +// from, and the basename tools/internal/openvex binds statements against. +var subjectNameAssignment = regexp.MustCompile(`subject-name=[^"'\s]*/([A-Za-z0-9._-]+)"`) + +// releasedImageBasenames derives the images the release actually publishes, +// rather than hardcoding them, so adding a third image extends the check +// instead of silently escaping it. +func releasedImageBasenames(t *testing.T) []string { + t.Helper() + seen := map[string]bool{} + for _, workflow := range []string{operatorCIPath, agentCIPath} { + for _, match := range subjectNameAssignment.FindAllStringSubmatch(readRepoFile(t, workflow), -1) { + seen[match[1]] = true + } + } + names := make([]string, 0, len(seen)) + for name := range seen { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// ociPURLName mirrors parseOCIPURLName in tools/internal/openvex: strip the +// pkg:oci/ prefix, then truncate at the first separator, so a product carrying +// a digest or a repository_url qualifier still yields its bare name. +func ociPURLName(identifier string) (string, bool) { + const prefix = "pkg:oci/" + if !strings.HasPrefix(identifier, prefix) { + return "", false + } + name := strings.TrimPrefix(identifier, prefix) + for _, separator := range []string{"@", "?", "#"} { + if at := strings.Index(name, separator); at >= 0 { + name = name[:at] + } + } + return name, name != "" +} + +type vexDocument struct { + Statements []struct { + Vulnerability struct { + Name string `json:"name"` + } `json:"vulnerability"` + Products []struct { + ID string `json:"@id"` + Identifiers struct { + PURL string `json:"purl"` + } `json:"identifiers"` + } `json:"products"` + } `json:"statements"` +} + +func TestReleasedImageBasenamesAreDerivable(t *testing.T) { + t.Parallel() + names := releasedImageBasenames(t) + if len(names) == 0 { + t.Fatalf(`no image basenames could be derived from %s or %s. +The product check below silently passes on an empty set, so the derivation is +asserted separately. If the workflows stopped publishing subject-name through a +shell assignment, update subjectNameAssignment rather than deleting this test.`, + operatorCIPath, agentCIPath) + } + t.Logf("released image basenames: %s", strings.Join(names, ", ")) +} + +func TestOpenVEXProductsNameAReleasedImage(t *testing.T) { + t.Parallel() + released := releasedImageBasenames(t) + known := map[string]bool{} + for _, name := range released { + known[name] = true + } + + var document vexDocument + if err := json.Unmarshal([]byte(readRepoFile(t, openVEXPath)), &document); err != nil { + t.Fatalf("parse %s: %v", openVEXPath, err) + } + + for index, statement := range document.Statements { + label := statement.Vulnerability.Name + if label == "" { + label = "" + } + // A product usually carries the same value in both @id and + // identifiers.purl, so report each distinct problem once rather than + // twice per product. + reported := map[string]bool{} + for _, product := range statement.Products { + for _, identifier := range []string{product.ID, product.Identifiers.PURL} { + if identifier == "" || reported[identifier] { + continue + } + reported[identifier] = true + name, ok := ociPURLName(identifier) + if !ok { + t.Errorf("statement %d (%s) has product identifier %q, which is not a pkg:oci purl; grype derives its product purl from the registry repository basename, so an identifier in any other shape matches nothing", + index, label, identifier) + continue + } + if !known[name] { + t.Errorf(`statement %d (%s) names image %q, which this repository does not release. +Released images: %s +A product naming no released image suppresses nothing and reports nothing: the +projection for every image comes out empty, which is valid OpenVEX, and grype +applies the statement to no scan. Check the name against the registry +repository basename, not the org.opencontainers.image.title label.`, + index, label, name, strings.Join(released, ", ")) + } + } + } + } +} diff --git a/tools/tests/releasepolicy/openvex_validation_test.go b/tools/tests/releasepolicy/openvex_validation_test.go new file mode 100644 index 000000000..f2624e03a --- /dev/null +++ b/tools/tests/releasepolicy/openvex_validation_test.go @@ -0,0 +1,215 @@ +// 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. + +// The rules `openvex validate` enforces are unit-tested in tools/internal/openvex. +// What is asserted here is that the action still calls it, in both modes: the +// source check and the projection check cover different bytes, so dropping +// either one during a refactor looks local and is not. Like the rest of the +// hand-written assertions, none of this is golden-driven. + +package releasepolicy + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// evidenceStepName is the step that generates and checks everything the release +// attests. Both OpenVEX documents pass through it. +const evidenceStepName = "Generate and validate per-platform evidence" + +func evidenceCommands(t *testing.T) []string { + t.Helper() + steps := loadAction(t, attestActionPath).Runs.Steps + return shellCommands(steps.named(t, evidenceStepName).Run) +} + +// indexOfCommand returns the position of the first command containing every +// given fragment, or -1. +func indexOfCommand(commands []string, fragments ...string) int { + for i, command := range commands { + matched := true + for _, fragment := range fragments { + if !strings.Contains(command, fragment) { + matched = false + break + } + } + if matched { + return i + } + } + return -1 +} + +// indexOfOpenVEX returns the position of the first command running the given +// ./cmd/openvex subcommand and carrying every extra fragment, or -1. +func indexOfOpenVEX(commands []string, subcommand string, fragments ...string) int { + for i, command := range commands { + if !openvexIs(command, subcommand) { + continue + } + matched := true + for _, fragment := range fragments { + if !strings.Contains(command, fragment) { + matched = false + break + } + } + if matched { + return i + } + } + return -1 +} + +// TestOpenVEXIsValidatedInBothModes pins that both checks survive. Source mode +// checks the committed document; projection mode checks the bytes cosign is +// about to sign, which binding rewrote. +func TestOpenVEXIsValidatedInBothModes(t *testing.T) { + t.Parallel() + ops := validations(actionOps(loadAction(t, attestActionPath))) + if len(ops) == 0 { + t.Fatalf("%s never runs `openvex validate`", attestActionPath) + } + + modes := map[string][]evidenceOp{} + for _, op := range ops { + modes[op.Mode] = append(modes[op.Mode], op) + } + + if len(modes["source"]) == 0 { + t.Errorf(`the committed .openvex.json is never validated in source mode. +A document that violates the OpenVEX v0.2.0 contract cannot produce a projection +worth signing, and one failure on the source is cheaper to read than the same +failure repeated once per platform.`) + } + if len(modes["projection"]) == 0 { + t.Fatalf(`the per-platform projection is never validated in projection mode. +That is the one check proving every product identifier is bound to a manifest a +scanner can match; without it a signed VEX can name an image nobody ships.`) + } + for _, op := range modes["projection"] { + if op.Digest == "" { + t.Errorf(`projection mode is invoked without -digest, so nothing checks what the products are bound to: + %s`, op.Command) + } + } + for mode := range modes { + if mode != "source" && mode != "projection" { + t.Errorf("`openvex validate` is invoked with -mode %q, which this policy does not know", mode) + } + } +} + +// TestOpenVEXProjectionIsCheckedAgainstThePlatformDigest pins what the +// projection check compares against. A projection validated against the index +// digest would pass while asserting a claim about a manifest no platform ships. +func TestOpenVEXProjectionIsCheckedAgainstThePlatformDigest(t *testing.T) { + t.Parallel() + ops := actionOps(loadAction(t, attestActionPath)) + + bound := map[string]bool{} + for _, op := range validations(ops) { + if op.Mode != "projection" { + continue + } + if op.Digest == indexDigestVar { + t.Errorf(`the digest passed to projection mode resolves to %s. +A projection checked against the index digest asserts a claim about a manifest no +platform ships, so the check would pass on a document no scanner can match. + %s`, indexDigestVar, op.Command) + } + bound[op.Digest] = true + } + for _, platform := range []string{amd64DigestVar, arm64DigestVar} { + if !bound[platform] { + t.Errorf(`no projection is validated against %s, so that platform's VEX document is signed without anything checking what its products bind to`, platform) + } + } +} + +// TestOpenVEXSourceIsValidatedBeforeItIsBound keeps the order meaningful. A +// source that violates the contract cannot produce a projection worth signing, +// and a projection is only worth checking after binding has rewritten it. +func TestOpenVEXSourceIsValidatedBeforeItIsBound(t *testing.T) { + t.Parallel() + commands := evidenceCommands(t) + + source := indexOfOpenVEX(commands, "validate", "-mode source") + bind := indexOfOpenVEX(commands, "bind") + projection := indexOfOpenVEX(commands, "validate", "-mode projection") + if source < 0 || bind < 0 || projection < 0 { + t.Fatalf("expected a source validation, a bind and a projection validation, got %d/%d/%d:\n%s", + source, bind, projection, strings.Join(commands, "\n")) + } + if !(source < bind && bind < projection) { + t.Errorf("expected source validation -> bind -> projection validation, got positions %d, %d, %d:\n%s", + source, bind, projection, strings.Join(commands, "\n")) + } +} + +// TestOpenVEXToolsRunFromTheToolsModule pins where the commands run from. The +// tools module is separate from operator/, so `go run ./cmd/openvex` issued +// from the workspace root resolves to nothing. +// +// The directory travels with each command as `go -C ` rather than through +// a preceding `cd` in a subshell. That is deliberate and it is what makes this +// assertion sound: a `cd` inside one subshell says nothing about a command in +// the next one, so a per-platform `cd` could be deleted while a test that only +// compared positions against the first `cd` kept passing. +func TestOpenVEXToolsRunFromTheToolsModule(t *testing.T) { + t.Parallel() + const wantDirectory = "${GITHUB_WORKSPACE}/tools" + + var found int + for _, command := range evidenceCommands(t) { + directory, subcommand, ok := openvexSubcommand(command) + if !ok { + continue + } + found++ + if directory != wantDirectory { + t.Errorf(`openvex %s runs with -C %q, want %q. +Without it the command resolves against whatever module the step happens to be +in, which is not the one holding ./cmd/openvex. + %s`, subcommand, directory, wantDirectory, command) + } + } + if found == 0 { + t.Error("the evidence step runs no ./cmd/openvex command at all") + } +} + +// TestOpenVEXRulesHaveOneImplementation guards the reason the bash guard was +// replaced rather than kept alongside the Go command. Two copies of the OpenVEX +// contract drift, and the copy that drifts is the one nothing unit-tests. +func TestOpenVEXRulesHaveOneImplementation(t *testing.T) { + t.Parallel() + + removed := filepath.Join(repoRoot(t), ".github/actions/cosign-attest-multiplatform/openvex-guard.sh") + if _, err := os.Stat(removed); err == nil { + t.Errorf("%s is back; the OpenVEX contract lives in tools/internal/openvex, and a second copy in bash cannot be unit-tested", removed) + } + + action := readRepoFile(t, attestActionPath) + for _, ghost := range []string{"openvex-guard.sh", "validate_openvex"} { + if strings.Contains(action, ghost) { + t.Errorf("%s still references %q, which the Go validator replaced", attestActionPath, ghost) + } + } +} diff --git a/tools/tests/releasepolicy/platform_resolution_test.go b/tools/tests/releasepolicy/platform_resolution_test.go new file mode 100644 index 000000000..17fec4f8a --- /dev/null +++ b/tools/tests/releasepolicy/platform_resolution_test.go @@ -0,0 +1,230 @@ +// 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. + +package releasepolicy + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const ( + testIndexDigest = "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + testAMD64Digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + testARM64Digest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + testSubjectName = "ghcr.io/nvidia/nodewright/operator" +) + +// TestPlatformResolutionFailsClosed drives the real resolution script with a +// fake crane. Every case here is one where crane itself is happy and the +// resolution is still wrong, which is why the script checks rather than trusts: +// a plain manifest pushed instead of an index answers `crane digest --platform` +// with its own digest and exit 0, and the action would then republish the exact +// bug it exists to fix, quietly. +func TestPlatformResolutionFailsClosed(t *testing.T) { + t.Parallel() + steps := loadAction(t, attestActionPath).Runs.Steps + resolve := steps.named(t, "Resolve per-platform digests") + if resolve.ID != "platforms" { + t.Fatalf("platform resolution step id = %q, want platforms (the action's outputs read steps.platforms)", resolve.ID) + } + if strings.TrimSpace(resolve.Run) == "" { + t.Fatal("could not extract the platform resolution script from the action") + } + + tests := []struct { + name string + // platforms is what `crane manifest` reports the index ships. Empty + // means the two platforms this action covers, which is what every case + // about digest resolution assumes. + platforms []string + amd64 string + arm64 string + wantErr bool + }{ + { + name: "a well-formed distinct pair resolves", + amd64: testAMD64Digest, + arm64: testARM64Digest, + }, + { + name: "an attestation manifest is not mistaken for a platform", + platforms: []string{"linux/amd64", "linux/arm64", "unknown/unknown"}, + amd64: testAMD64Digest, + arm64: testARM64Digest, + }, + { + name: "an index shipping a platform this action does not attest is rejected", + platforms: []string{"linux/amd64", "linux/arm64", "linux/ppc64le"}, + amd64: testAMD64Digest, + arm64: testARM64Digest, + wantErr: true, + }, + { + name: "amd64 resolving to the index means the reference is a plain manifest", + amd64: testIndexDigest, + arm64: testARM64Digest, + wantErr: true, + }, + { + name: "arm64 resolving to the index means the reference is a plain manifest", + amd64: testAMD64Digest, + arm64: testIndexDigest, + wantErr: true, + }, + { + name: "both platforms resolving alike means one was lost", + amd64: testAMD64Digest, + arm64: testAMD64Digest, + wantErr: true, + }, + { + name: "a malformed digest is rejected", + amd64: "sha256:not-a-digest", + arm64: testARM64Digest, + wantErr: true, + }, + { + name: "an absent platform is rejected", + amd64: testAMD64Digest, + arm64: "MISSING", + wantErr: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + bash := bashWithAssociativeArrays(t) + requireJQ(t) + + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.Mkdir(bin, 0o700); err != nil { + t.Fatalf("create fake bin: %v", err) + } + writeExecutable(t, filepath.Join(bin, "crane"), fakePlatformCrane) + writeExecutable(t, filepath.Join(bin, "timeout"), passthroughTimeout) + + table := writeFile(t, filepath.Join(dir, "platforms.tsv"), + fmt.Sprintf("linux/amd64\t%s\nlinux/arm64\t%s\n", test.amd64, test.arm64)) + + shipped := test.platforms + if shipped == nil { + shipped = []string{"linux/amd64", "linux/arm64"} + } + index := writeFile(t, filepath.Join(dir, "index-platforms"), + strings.Join(shipped, "\n")+"\n") + outputs := filepath.Join(dir, "github-output") + + command := exec.Command(bash, "-c", resolve.Run) + command.Env = append(os.Environ(), + "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), + "FAKE_PLATFORM_DIGESTS="+table, + "FAKE_INDEX_PLATFORMS="+index, + "SUBJECT_NAME="+testSubjectName, + "INDEX_DIGEST="+testIndexDigest, + "GITHUB_OUTPUT="+outputs, + ) + combined, err := command.CombinedOutput() + if (err != nil) != test.wantErr { + t.Fatalf("resolution error = %v, wantErr %t\n%s", err, test.wantErr, combined) + } + if test.wantErr { + if !strings.Contains(string(combined), "::error::") { + t.Errorf("resolution failed without an ::error:: annotation, so the cause would not surface in the job log:\n%s", combined) + } + return + } + written, readErr := os.ReadFile(outputs) + if readErr != nil { + t.Fatalf("read step outputs: %v", readErr) + } + // Parsed as key=value records rather than searched as text: a + // substring check is satisfied by a line like + // `not-amd64-digest=`, which does not set the key the + // action's outputs actually read. + outputRecords := map[string]string{} + for _, line := range strings.Split(string(written), "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), "=") + if found { + outputRecords[key] = value + } + } + for key, want := range map[string]string{"amd64-digest": test.amd64, "arm64-digest": test.arm64} { + got, present := outputRecords[key] + if !present { + t.Errorf("step outputs do not set %s; the action's outputs read steps.platforms.outputs.%s\n%s", key, key, written) + continue + } + if got != want { + t.Errorf("step output %s = %q, want %q", key, got, want) + } + } + }) + } +} + +// fakePlatformCrane answers `crane digest --platform ` +// from a table, reproducing crane's own exit 1 and "no child with platform" +// message when the index has no such child. It also reproduces the case the +// script exists for: a table entry equal to the index digest stands in for a +// plain manifest, which crane resolves successfully to itself. +// +// `crane manifest` is answered from FAKE_INDEX_PLATFORMS, one os/arch per line, +// so a case can describe an index that ships a platform the action does not +// attest. Defaulting it to the two covered platforms keeps every pre-existing +// case describing the index it always described. +const fakePlatformCrane = `#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "manifest" ]]; then + printf '{"manifests":[' + separator="" + while IFS=/ read -r os architecture; do + [[ -z "${os}" ]] && continue + printf '%s{"platform":{"os":"%s","architecture":"%s"}}' "${separator}" "${os}" "${architecture}" + separator="," + done < "${FAKE_INDEX_PLATFORMS}" + printf ']}\n' + exit 0 +fi +platform="" +previous="" +for argument in "$@"; do + if [[ "${previous}" == "--platform" ]]; then platform="${argument}"; fi + previous="${argument}" +done +while IFS=$'\t' read -r candidate digest; do + if [[ "${candidate}" == "${platform}" && "${digest}" != "MISSING" ]]; then + printf '%s\n' "${digest}" + exit 0 + fi +done < "${FAKE_PLATFORM_DIGESTS}" +echo "Error: no child with platform ${platform} in index" >&2 +exit 1 +` + +// passthroughTimeout runs the wrapped command directly. That the calls are +// bounded is asserted against the script text in TestCosignInvocationPolicy; a +// real timer here would only add a way for the test to hang. +const passthroughTimeout = `#!/usr/bin/env bash +set -euo pipefail +if [[ "${1:-}" == "--foreground" ]]; then shift; fi +shift +exec "$@" +` diff --git a/tools/tests/releasepolicy/release_workflow_test.go b/tools/tests/releasepolicy/release_workflow_test.go new file mode 100644 index 000000000..afe06b81d --- /dev/null +++ b/tools/tests/releasepolicy/release_workflow_test.go @@ -0,0 +1,415 @@ +// 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. + +// The assertions in this file are deliberately NOT golden-driven. A golden file +// is repaired by regenerating it, and for a policy that is the exact failure +// mode the policy exists to prevent: someone refactors, runs -update, CI turns +// green, and the SBOM is back on the index. Everything here is a rule that must +// survive a refactor whose author was sure it was equivalent, so each one is +// written out with the reason it exists in its failure message. + +package releasepolicy + +import ( + "strings" + "testing" +) + +// TestSBOMAndVEXTargetAPlatformManifest pins the one decision the whole change +// exists to make. An SBOM describes exactly one root filesystem and a VEX claim +// is verifiable only when its product identifier binds to the manifest it +// covers, so both hang on a platform manifest. Moving either back onto the +// index republishes the bug, and it would look like a one-line simplification +// in review. +func TestSBOMAndVEXTargetAPlatformManifest(t *testing.T) { + t.Parallel() + ops := actionOps(loadAction(t, attestActionPath)) + attests := attestations(ops) + if len(attests) == 0 { + t.Fatalf("%s publishes no attestations at all", attestActionPath) + } + + platforms := map[string]bool{amd64DigestVar: true, arm64DigestVar: true} + published := map[string]map[string]bool{} + for _, op := range attests { + switch { + case op.Subject == indexDigestVar: + t.Errorf(`%s step %q attests %s to %s. +SBOM and VEX evidence must hang on the platform manifest it describes, never on +the index: an SBOM on an index honestly describes neither child, and a consumer +who resolves linux/amd64 and enumerates referrers on that manifest finds nothing. + %s`, attestActionPath, op.Step, op.Label, op.Subject, op.Command) + case !platforms[op.Subject]: + t.Errorf(`%s step %q attests %s to %q, which is not one of the per-platform digest variables (%s, %s). +Every attestation subject has to resolve to a platform manifest digest; a subject +this policy cannot name is one nothing here can check. + %s`, attestActionPath, op.Step, op.Label, op.Subject, amd64DigestVar, arm64DigestVar, op.Command) + } + + predicate := strings.TrimPrefix(op.Label, "cosign attest ") + if predicate != "cyclonedx" && predicate != "openvex" { + t.Errorf("%s step %q runs cosign attest with predicate type %q, which this policy does not know:\n %s", + attestActionPath, op.Step, predicate, op.Command) + continue + } + if published[predicate] == nil { + published[predicate] = map[string]bool{} + } + published[predicate][op.Subject] = true + } + + // Both predicates on both platforms. A refactor that dropped one + // architecture's openvex attestation would still leave the assertions above + // passing on everything that remained. + for _, predicate := range []string{"cyclonedx", "openvex"} { + for _, platform := range []string{amd64DigestVar, arm64DigestVar} { + if !published[predicate][platform] { + t.Errorf(`no %s attestation targets %s. +Each platform manifest carries its own SBOM and its own VEX document; a missing +one leaves a referrers listing that reads as complete while one architecture +ships with no evidence of its own.`, predicate, platform) + } + } + } +} + +// TestSignatureTargetsTheIndex covers the other side of the split. The index is +// what a user pulls, so signing a platform manifest instead would leave the +// pulled artifact unsigned while every check still reported a signature. +func TestSignatureTargetsTheIndex(t *testing.T) { + t.Parallel() + signs := signatures(actionOps(loadAction(t, attestActionPath))) + if len(signs) != 1 { + t.Fatalf("%s runs cosign sign %d time(s), want exactly 1 (the index)", attestActionPath, len(signs)) + } + if signs[0].Subject != indexDigestVar { + t.Errorf(`%s step %q signs %s rather than %s. +The index is what a user pulls, so the signature belongs on it; signing a +platform manifest leaves the pulled artifact unsigned. + %s`, attestActionPath, signs[0].Step, signs[0].Subject, indexDigestVar, signs[0].Command) + } +} + +// TestDigestVariablesAreWiredToTheRightSource keeps the subject assertions from +// pinning variable names alone: a step could set AMD64_DIGEST from the index +// input and still read as per-platform everywhere else in this package. +func TestDigestVariablesAreWiredToTheRightSource(t *testing.T) { + t.Parallel() + steps := loadAction(t, attestActionPath).Runs.Steps + + sign := steps.named(t, "Sign index") + if got, want := sign.Env[indexDigestVar], "${{ inputs.index-digest }}"; got != want { + t.Errorf("Sign index env %s = %q, want %q", indexDigestVar, got, want) + } + + attest := steps.named(t, "Attest per-platform SBOM and VEX") + for _, wiring := range []struct{ variable, want string }{ + {amd64DigestVar, "${{ steps.platforms.outputs.amd64-digest }}"}, + {arm64DigestVar, "${{ steps.platforms.outputs.arm64-digest }}"}, + } { + if got := attest.Env[wiring.variable]; got != wiring.want { + t.Errorf("Attest step env %s = %q, want %q (the resolved platform digest, not an input)", + wiring.variable, got, wiring.want) + } + } + if _, present := attest.Env[indexDigestVar]; present { + t.Errorf("the attest step takes %s in its env; nothing it publishes belongs on the index, so the variable should not be reachable from it", indexDigestVar) + } +} + +// TestProvenanceStaysOnTheIndex pins the subject that did not move. SLSA +// provenance describes the build, not one root filesystem, so it stays on the +// index and stays with actions/attest-build-provenance in the calling workflow. +func TestProvenanceStaysOnTheIndex(t *testing.T) { + t.Parallel() + + t.Run("the attest action does no provenance", func(t *testing.T) { + t.Parallel() + steps := loadAction(t, attestActionPath).Runs.Steps + if used := steps.using("actions/attest-build-provenance"); len(used) > 0 { + t.Error("cosign-attest-multiplatform runs attest-build-provenance; provenance belongs to the calling workflow, on the index") + } + for _, command := range commandsInvoking(steps, "cosign attest") { + if strings.Contains(command, "slsa") || strings.Contains(command, "provenance") { + t.Errorf("the attest action publishes provenance itself: %s", command) + } + } + }) + + for _, workflow := range []string{operatorCIPath, agentCIPath} { + t.Run(workflow, func(t *testing.T) { + t.Parallel() + steps := loadWorkflow(t, workflow).steps() + + attests := steps.using("./.github/actions/cosign-attest-multiplatform") + if len(attests) != 1 { + t.Fatalf("%s calls cosign-attest-multiplatform %d times, want 1", workflow, len(attests)) + } + indexDigest := attests[0].With["index-digest"] + if indexDigest == "" { + t.Fatalf("%s does not pass index-digest to cosign-attest-multiplatform", workflow) + } + + provenance := steps.using("actions/attest-build-provenance") + if len(provenance) != 1 { + t.Fatalf("%s runs attest-build-provenance %d times, want 1", workflow, len(provenance)) + } + if got := provenance[0].With["subject-digest"]; got != indexDigest { + t.Errorf(`%s attests provenance to %q while the index digest is %q. +SLSA provenance is about the build that produced the artifact, not about one +root filesystem, so it must stay on the index the user pulls.`, workflow, got, indexDigest) + } + }) + } +} + +// TestEveryCosignCallPinsTheBundleFormat is asserted across every file that runs +// cosign, producing and consuming alike. The flag decides whether evidence +// publishes through the referrers API or a legacy .att tag, and its default has +// moved between cosign releases. A producer and a consumer that disagree do not +// error: the consumer looks in the wrong place and reports the evidence missing. +func TestEveryCosignCallPinsTheBundleFormat(t *testing.T) { + t.Parallel() + for _, path := range []string{attestActionPath, verifyActionPath, chartSignActionPath} { + t.Run(path, func(t *testing.T) { + t.Parallel() + steps := loadAction(t, path).Runs.Steps + // Both verify forms are named explicitly. Matching is token-aware + // from the executable position, so `cosign verify` no longer also + // matches `cosign verify-attestation`; they are different commands + // and each has to be swept on its own. + var found int + for _, program := range []string{"cosign sign", "cosign attest", "cosign verify", "cosign verify-attestation"} { + for _, command := range commandsInvoking(steps, program) { + found++ + if !hasFlag(command, "--new-bundle-format=true") { + t.Errorf(`%s runs cosign without --new-bundle-format=true: + %s +The flag decides whether evidence lands through the OCI referrers API or as a +legacy .att tag, and its default has moved between cosign releases. Signing and +verification disagreeing about it does not error, it reports the evidence as +missing.`, path, command) + } + } + } + if found == 0 { + t.Errorf("no cosign invocation found in %s; this assertion is only meaningful if it has calls to check", path) + } + }) + } +} + +// TestRegistryCallsAreBounded pins that no call to a registry or to Sigstore can +// hang a release job until the runner is killed. Verification is the worst case +// and gets its own subtest: it runs last, after the artifacts are pushed, which +// is the point at which a hang is least visible and most expensive. +func TestRegistryCallsAreBounded(t *testing.T) { + t.Parallel() + + t.Run("publishing calls", func(t *testing.T) { + t.Parallel() + steps := loadAction(t, attestActionPath).Runs.Steps + for _, program := range []string{"cosign sign", "cosign attest", "crane digest"} { + commands := commandsInvoking(steps, program) + if len(commands) == 0 { + t.Errorf("no %s invocation found in %s", program, attestActionPath) + } + for _, command := range commands { + if !timeoutWrapped(command, program) { + t.Errorf(`%s is not wrapped in timeout: + %s +An unbounded registry call hangs the release job until the runner is killed.`, program, command) + } + } + } + }) + + t.Run("verification calls", func(t *testing.T) { + t.Parallel() + steps := loadAction(t, verifyActionPath).Runs.Steps + for _, program := range []string{"cosign verify", "cosign verify-attestation"} { + commands := commandsInvoking(steps, program) + if len(commands) == 0 { + t.Errorf("no %s invocation found in %s", program, verifyActionPath) + } + for _, command := range commands { + if !timeoutWrapped(command, program) { + t.Errorf(`%s is not wrapped in timeout: + %s +Verification runs after the artifacts are pushed, so an unbounded call hangs the +release at its least recoverable point.`, program, command) + } + } + } + }) +} + +// TestCosignVersionPinsAgree is the assertion that exists because a human +// comment is otherwise the only thing holding the invariant together: three +// separate files repeat the same version literal and none reads it from +// another. All three matter, and the chart's is the one easiest to forget, +// because it is not part of the per-platform split and looks unrelated. +func TestCosignVersionPinsAgree(t *testing.T) { + t.Parallel() + attestPin := loadAction(t, attestActionPath).Inputs["cosign-version"].Default + if attestPin == "" { + t.Fatalf("%s declares no inputs.cosign-version.default", attestActionPath) + } + + // The verify action covers both the image path and the chart path, so a + // disagreement with either signing side fails a release at its last step, + // after the artifact is already pushed. + for _, path := range []string{verifyActionPath, chartSignActionPath} { + installers := loadAction(t, path).Runs.Steps.using("sigstore/cosign-installer") + if len(installers) != 1 { + t.Fatalf("%s installs cosign %d times, want 1", path, len(installers)) + } + pin := installers[0].With["cosign-release"] + if pin == "" { + t.Errorf(`%s installs cosign without pinning cosign-release. +Leaving it to the installer's default makes the cosign version an implicit dependency on the installer's SHA, which Renovate bumps routinely. A bump to an installer defaulting to a different cosign major would change the on-registry bundle layout under a verification step that pins %q explicitly.`, + path, attestPin) + continue + } + if pin != attestPin { + t.Errorf(`cosign pins disagree: %s declares inputs.cosign-version.default = %q, %s installs cosign-release: %q. +A release must install one cosign version across sign, attest and verify: verification reads the on-registry layout that signing produced, and --new-bundle-format has to mean the same thing on both sides. Update every file together.`, + attestActionPath, attestPin, path, pin) + } + } +} + +// TestImageWorkflowsAttachNoEvidenceToTheIndex covers what the action alone +// cannot. The action can only attest where it is told to, so a workflow that +// reintroduced the single-subject action beside it would put an SBOM back on the +// index without touching the action at all. +func TestImageWorkflowsAttachNoEvidenceToTheIndex(t *testing.T) { + t.Parallel() + for _, workflow := range []string{operatorCIPath, agentCIPath} { + t.Run(workflow, func(t *testing.T) { + t.Parallel() + steps := loadWorkflow(t, workflow).steps() + + if used := steps.using("./.github/actions/cosign-sign-sbom"); len(used) > 0 { + t.Errorf("%s uses cosign-sign-sbom, which attaches one SBOM to the subject it is given; a multi-platform image must use cosign-attest-multiplatform instead", workflow) + } + for _, step := range steps { + for _, command := range shellCommands(step.Run) { + if invokes(command, "cosign attest") || invokes(command, "cosign sign") { + t.Errorf(`%s step %q signs or attests inline: + %s +All image evidence goes through cosign-attest-multiplatform so the subject policy +lives in one place.`, workflow, step.Name, command) + } + } + } + if len(steps.using("./.github/actions/cosign-attest-multiplatform")) != 1 { + t.Errorf("%s must call ./.github/actions/cosign-attest-multiplatform exactly once", workflow) + } + }) + } +} + +// TestImageWorkflowWiring pins the seam between the two actions. The attest +// action resolves the platform digests, and only the workflow can carry them to +// verification; a dropped output would leave verification checking nothing while +// the job still reported success. +func TestImageWorkflowWiring(t *testing.T) { + t.Parallel() + for _, workflow := range []string{operatorCIPath, agentCIPath} { + t.Run(workflow, func(t *testing.T) { + t.Parallel() + steps := loadWorkflow(t, workflow).steps() + + attests := steps.using("./.github/actions/cosign-attest-multiplatform") + if len(attests) != 1 { + t.Fatalf("%s calls cosign-attest-multiplatform %d times, want 1", workflow, len(attests)) + } + attest := attests[0] + if attest.With["index-digest"] == "" { + t.Error("the attest step must be passed index-digest") + } + if attest.With["subject-name"] == "" { + t.Error("the attest step must be passed subject-name") + } + if attest.ID == "" { + t.Fatal("the attest step needs an id; its platform digest outputs are unreachable without one") + } + + declared := steps.ids() + verifies := steps.using("./.github/actions/cosign-verify-release") + if len(verifies) != 1 { + t.Fatalf("%s calls cosign-verify-release %d times, want 1", workflow, len(verifies)) + } + for _, output := range []string{"amd64-digest", "arm64-digest"} { + want := "${{ steps." + attest.ID + ".outputs." + output + " }}" + got := verifies[0].With[output] + if got != want { + t.Errorf(`%s verify step %s = %q, want %q (the attest step's output). +Verification has to follow the evidence to the platform manifests it was +published against; a digest that does not come from the attest step leaves +verification checking a subject nothing attested.`, workflow, output, got, want) + } + referenced := referencedStepID(got) + if referenced == "" || !declared[referenced] { + t.Errorf("%s verify step %s references step id %q, which no step in the workflow declares", workflow, output, referenced) + } + } + }) + } +} + +// TestHelmChartKeepsSingleSubjectEvidence pins the deliberate exception. The +// chart is a single OCI artifact with no platform children, so per-platform +// evidence there would describe nothing, and the multi-platform action would +// fail closed trying to resolve children that do not exist. +func TestHelmChartKeepsSingleSubjectEvidence(t *testing.T) { + t.Parallel() + steps := loadWorkflow(t, releasePath).steps() + + if len(steps.using("./.github/actions/cosign-sign-sbom")) != 1 { + t.Errorf("%s must keep signing the Helm chart with cosign-sign-sbom", releasePath) + } + if used := steps.using("./.github/actions/cosign-attest-multiplatform"); len(used) > 0 { + t.Errorf("%s uses cosign-attest-multiplatform; the Helm chart has no platform children to split evidence across", releasePath) + } + + verifies := steps.using("./.github/actions/cosign-verify-release") + if len(verifies) != 1 { + t.Fatalf("%s calls cosign-verify-release %d times, want 1", releasePath, len(verifies)) + } + for _, input := range []string{"amd64-digest", "arm64-digest"} { + if value, present := verifies[0].With[input]; present { + t.Errorf(`%s passes %s = %q to chart verification. +A chart has no platforms, so every check must run against the one subject; a +platform digest here would send verification looking for evidence on a manifest +that does not exist.`, releasePath, input, value) + } + } +} + +// referencedStepID pulls the step id out of a `${{ steps..outputs. }}` +// expression, returning "" when the expression is not of that shape. +func referencedStepID(expression string) string { + const prefix = "${{ steps." + if !strings.HasPrefix(expression, prefix) { + return "" + } + id, _, found := strings.Cut(expression[len(prefix):], ".") + if !found { + return "" + } + return id +} diff --git a/tools/tests/releasepolicy/testdata/evidence-layout.golden b/tools/tests/releasepolicy/testdata/evidence-layout.golden new file mode 100644 index 000000000..b38b6f7c1 --- /dev/null +++ b/tools/tests/releasepolicy/testdata/evidence-layout.golden @@ -0,0 +1,59 @@ +# The evidence layout this release path publishes, rendered from the composite +# actions and workflows that publish it. Regenerate with: +# +# cd tools/tests && go test ./releasepolicy -update +# +# Read a diff here as a claim about what a release publishes. A line whose arrow +# target moved between the index digest and a platform digest is the bug this +# package exists to catch, and regenerating the golden would hide it. That is +# why the subject rules, the cosign flags and the version pins are ALSO asserted +# by hand in release_workflow_test.go, where -update cannot reach them. +# +# Subjects are shown as the shell variable carrying the digest; the env lines +# under each action say where that variable comes from. The per-platform +# `for arch` loops are unrolled, so each platform's evidence gets its own line. +# +# cosign-verify-release is not rendered: its cosign calls take their subject +# through a shell function parameter, which this renderer does not follow. The +# workflow sections show which digests each workflow hands it. + +== .github/actions/cosign-attest-multiplatform/action.yml == +env INDEX_DIGEST = ${{ inputs.index-digest }} +env AMD64_DIGEST = ${{ steps.platforms.outputs.amd64-digest }} +env ARM64_DIGEST = ${{ steps.platforms.outputs.arm64-digest }} + +openvex validate -mode source +openvex bind -digest @AMD64_DIGEST +openvex validate -mode projection -digest @AMD64_DIGEST +openvex bind -digest @ARM64_DIGEST +openvex validate -mode projection -digest @ARM64_DIGEST +cosign sign -> @INDEX_DIGEST [--new-bundle-format=true, timeout 120s] +cosign attest cyclonedx -> @AMD64_DIGEST [--new-bundle-format=true, timeout 120s] +cosign attest openvex -> @AMD64_DIGEST [--new-bundle-format=true, timeout 120s] +cosign attest cyclonedx -> @ARM64_DIGEST [--new-bundle-format=true, timeout 120s] +cosign attest openvex -> @ARM64_DIGEST [--new-bundle-format=true, timeout 120s] + +== .github/actions/cosign-sign-sbom/action.yml == +env SUBJECT_DIGEST = ${{ inputs.subject-digest }} + +cosign sign -> @SUBJECT_DIGEST [--new-bundle-format=true, timeout 120s] +cosign attest cyclonedx -> @SUBJECT_DIGEST [--new-bundle-format=true, timeout 120s] + +== .github/workflows/operator-ci.yaml == +cosign-attest-multiplatform index-digest -> ${{ steps.digest.outputs.digest }} +attest-build-provenance subject-digest -> ${{ steps.digest.outputs.digest }} +cosign-verify-release subject-digest -> ${{ steps.digest.outputs.digest }} +cosign-verify-release amd64-digest -> ${{ steps.attest.outputs.amd64-digest }} +cosign-verify-release arm64-digest -> ${{ steps.attest.outputs.arm64-digest }} + +== .github/workflows/agent-ci.yaml == +cosign-attest-multiplatform index-digest -> ${{ steps.digest.outputs.digest }} +attest-build-provenance subject-digest -> ${{ steps.digest.outputs.digest }} +cosign-verify-release subject-digest -> ${{ steps.digest.outputs.digest }} +cosign-verify-release amd64-digest -> ${{ steps.attest.outputs.amd64-digest }} +cosign-verify-release arm64-digest -> ${{ steps.attest.outputs.arm64-digest }} + +== .github/workflows/release.yml == +cosign-sign-sbom subject-digest -> ${{ steps.push-chart.outputs.digest }} +attest-build-provenance subject-digest -> ${{ steps.push-chart.outputs.digest }} +cosign-verify-release subject-digest -> ${{ steps.push-chart.outputs.digest }}